summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGerardo Zamudio <gerardozamudio@users.noreply.github.com>2016-02-24 23:48:50 -0600
committerGerardo Zamudio <gerardozamudio@users.noreply.github.com>2016-02-24 23:48:50 -0600
commite4de6b3898b64b26d29aff31f21df5fda8055686 (patch)
tree575f8a65440f291d70a070d168eafca8c82a6459
parentd9ffbea174ea6524d0a22f8375ca8b3aa04a3c96 (diff)
parenta6540bc604c837d92c9368540c145606723e97f7 (diff)
Merge pull request #1 from fguillot/master
Update from upstream
-rw-r--r--.docker/crontab/kanboard1
-rw-r--r--.docker/kanboard/config.php3
-rw-r--r--.docker/nginx/nginx.conf71
-rw-r--r--.docker/php/conf.d/local.ini15
-rw-r--r--.docker/php/php-fpm.conf17
-rwxr-xr-x.docker/services.d/.s6-svscan/finish2
-rwxr-xr-x.docker/services.d/cron/run2
-rwxr-xr-x.docker/services.d/nginx/run2
-rwxr-xr-x.docker/services.d/php/run2
-rw-r--r--.gitignore2
-rw-r--r--CONTRIBUTORS.md21
-rw-r--r--ChangeLog273
-rw-r--r--Dockerfile42
-rw-r--r--LICENSE2
-rw-r--r--Makefile51
-rw-r--r--README.md48
-rw-r--r--app/Action/Base.php132
-rw-r--r--app/Action/CommentCreation.php30
-rw-r--r--app/Action/CommentCreationMoveTaskColumn.php (renamed from app/Action/TaskLogMoveAnotherColumn.php)17
-rw-r--r--app/Action/TaskAssignCategoryColor.php11
-rw-r--r--app/Action/TaskAssignCategoryLabel.php21
-rw-r--r--app/Action/TaskAssignCategoryLink.php101
-rw-r--r--app/Action/TaskAssignColorCategory.php13
-rw-r--r--app/Action/TaskAssignColorColumn.php13
-rw-r--r--app/Action/TaskAssignColorLink.php13
-rw-r--r--app/Action/TaskAssignColorUser.php13
-rw-r--r--app/Action/TaskAssignCurrentUser.php19
-rw-r--r--app/Action/TaskAssignCurrentUserColumn.php98
-rw-r--r--app/Action/TaskAssignSpecificUser.php11
-rw-r--r--app/Action/TaskAssignUser.php21
-rw-r--r--app/Action/TaskClose.php62
-rw-r--r--app/Action/TaskCloseColumn.php84
-rw-r--r--app/Action/TaskCloseNoActivity.php95
-rw-r--r--app/Action/TaskCreation.php21
-rw-r--r--app/Action/TaskDuplicateAnotherProject.php15
-rw-r--r--app/Action/TaskEmail.php11
-rw-r--r--app/Action/TaskEmailNoActivity.php124
-rw-r--r--app/Action/TaskMoveAnotherProject.php11
-rw-r--r--app/Action/TaskMoveColumnAssigned.php12
-rw-r--r--app/Action/TaskMoveColumnCategoryChange.php15
-rw-r--r--app/Action/TaskMoveColumnUnAssigned.php12
-rw-r--r--app/Action/TaskOpen.php19
-rw-r--r--app/Action/TaskUpdateStartDate.php13
-rw-r--r--app/Analytic/AverageLeadCycleTimeAnalytic.php114
-rw-r--r--app/Analytic/AverageTimeSpentColumnAnalytic.php153
-rw-r--r--app/Analytic/EstimatedTimeComparisonAnalytic.php50
-rw-r--r--app/Analytic/TaskDistributionAnalytic.php48
-rw-r--r--app/Analytic/UserDistributionAnalytic.php56
-rw-r--r--app/Api/Action.php30
-rw-r--r--app/Api/App.php10
-rw-r--r--app/Api/Auth.php52
-rw-r--r--app/Api/Board.php35
-rw-r--r--app/Api/Category.php4
-rw-r--r--app/Api/Column.php42
-rw-r--r--app/Api/Comment.php7
-rw-r--r--app/Api/File.php58
-rw-r--r--app/Api/Group.php49
-rw-r--r--app/Api/GroupMember.php32
-rw-r--r--app/Api/Link.php4
-rw-r--r--app/Api/Me.php16
-rw-r--r--app/Api/Project.php4
-rw-r--r--app/Api/ProjectPermission.php53
-rw-r--r--app/Api/Subtask.php4
-rw-r--r--app/Api/Swimlane.php11
-rw-r--r--app/Api/Task.php44
-rw-r--r--app/Api/User.php81
-rw-r--r--app/Auth/Database.php49
-rw-r--r--app/Auth/DatabaseAuth.php126
-rw-r--r--app/Auth/Github.php123
-rw-r--r--app/Auth/Gitlab.php123
-rw-r--r--app/Auth/Google.php124
-rw-r--r--app/Auth/Ldap.php521
-rw-r--r--app/Auth/LdapAuth.php172
-rw-r--r--app/Auth/RememberMe.php323
-rw-r--r--app/Auth/RememberMeAuth.php79
-rw-r--r--app/Auth/ReverseProxy.php83
-rw-r--r--app/Auth/ReverseProxyAuth.php76
-rw-r--r--app/Auth/TotpAuth.php144
-rw-r--r--app/Console/Base.php25
-rw-r--r--app/Console/Cronjob.php32
-rw-r--r--app/Console/TaskTrigger.php51
-rw-r--r--app/Controller/Action.php47
-rw-r--r--app/Controller/Activity.php5
-rw-r--r--app/Controller/Analytic.php108
-rw-r--r--app/Controller/App.php79
-rw-r--r--app/Controller/Auth.php43
-rw-r--r--app/Controller/Base.php259
-rw-r--r--app/Controller/Board.php220
-rw-r--r--app/Controller/BoardPopover.php135
-rw-r--r--app/Controller/BoardTooltip.php126
-rw-r--r--app/Controller/Calendar.php2
-rw-r--r--app/Controller/Captcha.php29
-rw-r--r--app/Controller/Category.php24
-rw-r--r--app/Controller/Column.php92
-rw-r--r--app/Controller/Comment.php49
-rw-r--r--app/Controller/Config.php58
-rw-r--r--app/Controller/Currency.php30
-rw-r--r--app/Controller/Customfilter.php41
-rw-r--r--app/Controller/Doc.php4
-rw-r--r--app/Controller/Export.php6
-rw-r--r--app/Controller/Feed.php4
-rw-r--r--app/Controller/File.php192
-rw-r--r--app/Controller/FileViewer.php116
-rw-r--r--app/Controller/Gantt.php33
-rw-r--r--app/Controller/Group.php250
-rw-r--r--app/Controller/GroupHelper.php24
-rw-r--r--app/Controller/Ical.php3
-rw-r--r--app/Controller/Link.php38
-rw-r--r--app/Controller/Listing.php5
-rw-r--r--app/Controller/Oauth.php83
-rw-r--r--app/Controller/PasswordReset.php120
-rw-r--r--app/Controller/Project.php261
-rw-r--r--app/Controller/ProjectCreation.php125
-rw-r--r--app/Controller/ProjectEdit.php125
-rw-r--r--app/Controller/ProjectFile.php79
-rw-r--r--app/Controller/ProjectOverview.php29
-rw-r--r--app/Controller/ProjectPermission.php191
-rw-r--r--app/Controller/Projectuser.php51
-rw-r--r--app/Controller/Search.php5
-rw-r--r--app/Controller/Subtask.php160
-rw-r--r--app/Controller/SubtaskRestriction.php61
-rw-r--r--app/Controller/SubtaskStatus.php72
-rw-r--r--app/Controller/Swimlane.php129
-rw-r--r--app/Controller/Task.php32
-rw-r--r--app/Controller/TaskExternalLink.php185
-rw-r--r--app/Controller/TaskFile.php98
-rw-r--r--app/Controller/TaskHelper.php57
-rw-r--r--app/Controller/TaskImport.php6
-rw-r--r--app/Controller/TaskRecurrence.php61
-rw-r--r--app/Controller/Taskcreation.php44
-rw-r--r--app/Controller/Taskduplication.php24
-rw-r--r--app/Controller/Tasklink.php65
-rw-r--r--app/Controller/Taskmodification.php142
-rw-r--r--app/Controller/Taskstatus.php46
-rw-r--r--app/Controller/Timer.php34
-rw-r--r--app/Controller/Twofactor.php113
-rw-r--r--app/Controller/User.php178
-rw-r--r--app/Controller/UserHelper.php37
-rw-r--r--app/Controller/UserImport.php6
-rw-r--r--app/Controller/UserStatus.php111
-rw-r--r--app/Controller/Webhook.php53
-rw-r--r--app/Core/Action/ActionManager.php142
-rw-r--r--app/Core/Base.php94
-rw-r--r--app/Core/Cache/MemoryCache.php2
-rw-r--r--app/Core/Csv.php13
-rw-r--r--app/Core/DateParser.php215
-rw-r--r--app/Core/Event/EventManager.php63
-rw-r--r--app/Core/ExternalLink/ExternalLinkInterface.php36
-rw-r--r--app/Core/ExternalLink/ExternalLinkManager.php173
-rw-r--r--app/Core/ExternalLink/ExternalLinkProviderInterface.php71
-rw-r--r--app/Core/ExternalLink/ExternalLinkProviderNotFound.php15
-rw-r--r--app/Core/Group/GroupBackendProviderInterface.php21
-rw-r--r--app/Core/Group/GroupManager.php71
-rw-r--r--app/Core/Group/GroupProviderInterface.php40
-rw-r--r--app/Core/Helper.php1
-rw-r--r--app/Core/Http/Client.php (renamed from app/Core/HttpClient.php)69
-rw-r--r--app/Core/Http/OAuth2.php (renamed from app/Core/OAuth2.php)8
-rw-r--r--app/Core/Http/RememberMeCookie.php120
-rw-r--r--app/Core/Http/Request.php337
-rw-r--r--app/Core/Http/Response.php (renamed from app/Core/Response.php)23
-rw-r--r--app/Core/Http/Route.php187
-rw-r--r--app/Core/Http/Router.php169
-rw-r--r--app/Core/Ldap/Client.php165
-rw-r--r--app/Core/Ldap/ClientException.php15
-rw-r--r--app/Core/Ldap/Entries.php63
-rw-r--r--app/Core/Ldap/Entry.php91
-rw-r--r--app/Core/Ldap/Group.php131
-rw-r--r--app/Core/Ldap/Query.php87
-rw-r--r--app/Core/Ldap/User.php224
-rw-r--r--app/Core/Lexer.php2
-rw-r--r--app/Core/Mail/Client.php20
-rw-r--r--app/Core/Mail/Transport/Mail.php2
-rw-r--r--app/Core/Mail/Transport/Sendmail.php2
-rw-r--r--app/Core/Mail/Transport/Smtp.php2
-rw-r--r--app/Core/Markdown.php80
-rw-r--r--app/Core/Plugin/Loader.php6
-rw-r--r--app/Core/Request.php240
-rw-r--r--app/Core/Router.php229
-rw-r--r--app/Core/Security.php86
-rw-r--r--app/Core/Security/AccessMap.php175
-rw-r--r--app/Core/Security/AuthenticationManager.php187
-rw-r--r--app/Core/Security/AuthenticationProviderInterface.php28
-rw-r--r--app/Core/Security/Authorization.php46
-rw-r--r--app/Core/Security/OAuthAuthenticationProviderInterface.php46
-rw-r--r--app/Core/Security/PasswordAuthenticationProviderInterface.php36
-rw-r--r--app/Core/Security/PostAuthenticationProviderInterface.php69
-rw-r--r--app/Core/Security/PreAuthenticationProviderInterface.php20
-rw-r--r--app/Core/Security/Role.php64
-rw-r--r--app/Core/Security/SessionCheckProviderInterface.php20
-rw-r--r--app/Core/Security/Token.php61
-rw-r--r--app/Core/Session.php143
-rw-r--r--app/Core/Session/FlashMessage.php71
-rw-r--r--app/Core/Session/SessionManager.php110
-rw-r--r--app/Core/Session/SessionStorage.php89
-rw-r--r--app/Core/Template.php53
-rw-r--r--app/Core/Tool.php3
-rw-r--r--app/Core/Translator.php28
-rw-r--r--app/Core/User/GroupSync.php32
-rw-r--r--app/Core/User/UserProfile.php62
-rw-r--r--app/Core/User/UserProperty.php70
-rw-r--r--app/Core/User/UserProviderInterface.php103
-rw-r--r--app/Core/User/UserSession.php204
-rw-r--r--app/Core/User/UserSync.php76
-rw-r--r--app/Event/AuthEvent.php27
-rw-r--r--app/Event/AuthFailureEvent.php44
-rw-r--r--app/Event/AuthSuccessEvent.php43
-rw-r--r--app/Event/GenericEvent.php2
-rw-r--r--app/Event/TaskListEvent.php11
-rw-r--r--app/ExternalLink/AttachmentLink.php26
-rw-r--r--app/ExternalLink/AttachmentLinkProvider.php117
-rw-r--r--app/ExternalLink/BaseLink.php44
-rw-r--r--app/ExternalLink/BaseLinkProvider.php33
-rw-r--r--app/ExternalLink/WebLink.php37
-rw-r--r--app/ExternalLink/WebLinkProvider.php77
-rw-r--r--app/Formatter/GroupAutoCompleteFormatter.php55
-rw-r--r--app/Formatter/ProjectGanttFormatter.php2
-rw-r--r--app/Formatter/TaskFilterGanttFormatter.php2
-rw-r--r--app/Formatter/UserFilterAutoCompleteFormatter.php38
-rw-r--r--app/Group/DatabaseBackendGroupProvider.php34
-rw-r--r--app/Group/DatabaseGroupProvider.php66
-rw-r--r--app/Group/LdapBackendGroupProvider.php54
-rw-r--r--app/Group/LdapGroupProvider.php76
-rw-r--r--app/Helper/App.php73
-rw-r--r--app/Helper/Dt.php46
-rw-r--r--app/Helper/File.php57
-rw-r--r--app/Helper/Form.php6
-rw-r--r--app/Helper/Layout.php171
-rw-r--r--app/Helper/Subtask.php88
-rw-r--r--app/Helper/Task.php151
-rw-r--r--app/Helper/Text.php30
-rw-r--r--app/Helper/Url.php25
-rw-r--r--app/Helper/User.php82
-rw-r--r--app/Integration/BitbucketWebhook.php312
-rw-r--r--app/Integration/GithubWebhook.php380
-rw-r--r--app/Integration/GitlabWebhook.php265
-rw-r--r--app/Locale/bs_BA/translations.php1151
-rw-r--r--app/Locale/cs_CZ/translations.php326
-rw-r--r--app/Locale/da_DK/translations.php326
-rw-r--r--app/Locale/de_DE/translations.php406
-rw-r--r--app/Locale/el_GR/translations.php1151
-rw-r--r--app/Locale/es_ES/translations.php326
-rw-r--r--app/Locale/fi_FI/translations.php326
-rw-r--r--app/Locale/fr_FR/translations.php333
-rw-r--r--app/Locale/hu_HU/translations.php326
-rw-r--r--app/Locale/id_ID/translations.php326
-rw-r--r--app/Locale/it_IT/translations.php1514
-rw-r--r--app/Locale/ja_JP/translations.php326
-rw-r--r--app/Locale/my_MY/translations.php1151
-rw-r--r--app/Locale/nb_NO/translations.php326
-rw-r--r--app/Locale/nl_NL/translations.php326
-rw-r--r--app/Locale/pl_PL/translations.php738
-rw-r--r--app/Locale/pt_BR/translations.php480
-rw-r--r--app/Locale/pt_PT/translations.php418
-rw-r--r--app/Locale/ru_RU/translations.php546
-rw-r--r--app/Locale/sr_Latn_RS/translations.php326
-rw-r--r--app/Locale/sv_SE/translations.php326
-rw-r--r--app/Locale/th_TH/translations.php937
-rw-r--r--app/Locale/tr_TR/translations.php1126
-rw-r--r--app/Locale/zh_CN/translations.php900
-rw-r--r--app/Model/Acl.php289
-rw-r--r--app/Model/Action.php350
-rw-r--r--app/Model/ActionParameter.php162
-rw-r--r--app/Model/Authentication.php202
-rw-r--r--app/Model/Board.php315
-rw-r--r--app/Model/Category.php73
-rw-r--r--app/Model/Column.php209
-rw-r--r--app/Model/Comment.php70
-rw-r--r--app/Model/Config.php125
-rw-r--r--app/Model/Currency.php53
-rw-r--r--app/Model/CustomFilter.php62
-rw-r--r--app/Model/File.php300
-rw-r--r--app/Model/Group.php117
-rw-r--r--app/Model/GroupMember.php111
-rw-r--r--app/Model/LastLogin.php42
-rw-r--r--app/Model/Link.php45
-rw-r--r--app/Model/Metadata.php13
-rw-r--r--app/Model/Notification.php58
-rw-r--r--app/Model/PasswordReset.php93
-rw-r--r--app/Model/Project.php142
-rw-r--r--app/Model/ProjectActivity.php14
-rw-r--r--app/Model/ProjectAnalytic.php182
-rw-r--r--app/Model/ProjectDailyColumnStats.php280
-rw-r--r--app/Model/ProjectDailyStats.php45
-rw-r--r--app/Model/ProjectDuplication.php140
-rw-r--r--app/Model/ProjectFile.php40
-rw-r--r--app/Model/ProjectGroupRole.php190
-rw-r--r--app/Model/ProjectGroupRoleFilter.php89
-rw-r--r--app/Model/ProjectPermission.php448
-rw-r--r--app/Model/ProjectUserRole.php276
-rw-r--r--app/Model/ProjectUserRoleFilter.php88
-rw-r--r--app/Model/RememberMeSession.php151
-rw-r--r--app/Model/Setting.php6
-rw-r--r--app/Model/Subtask.php192
-rw-r--r--app/Model/SubtaskTimeTracking.php10
-rw-r--r--app/Model/Swimlane.php321
-rw-r--r--app/Model/Task.php25
-rw-r--r--app/Model/TaskAnalytic.php2
-rw-r--r--app/Model/TaskCreation.php21
-rw-r--r--app/Model/TaskDuplication.php8
-rw-r--r--app/Model/TaskExport.php6
-rw-r--r--app/Model/TaskExternalLink.php99
-rw-r--r--app/Model/TaskFile.php54
-rw-r--r--app/Model/TaskFilter.php43
-rw-r--r--app/Model/TaskFinder.php41
-rw-r--r--app/Model/TaskImport.php2
-rw-r--r--app/Model/TaskLink.php64
-rw-r--r--app/Model/TaskModification.php17
-rw-r--r--app/Model/TaskPermission.php4
-rw-r--r--app/Model/TaskPosition.php17
-rw-r--r--app/Model/TaskStatus.php32
-rw-r--r--app/Model/Transition.php8
-rw-r--r--app/Model/User.php321
-rw-r--r--app/Model/UserFilter.php80
-rw-r--r--app/Model/UserImport.php18
-rw-r--r--app/Model/UserLocking.php103
-rw-r--r--app/Model/UserMention.php61
-rw-r--r--app/Model/UserNotification.php50
-rw-r--r--app/Model/UserSession.php177
-rw-r--r--app/Notification/Mail.php15
-rw-r--r--app/Notification/Web.php7
-rw-r--r--app/Schema/Mysql.php212
-rw-r--r--app/Schema/Postgres.php206
-rw-r--r--app/Schema/Sql/mysql.sql101
-rw-r--r--app/Schema/Sql/postgres.sql283
-rw-r--r--app/Schema/Sqlite.php186
-rw-r--r--app/ServiceProvider/ActionProvider.php82
-rw-r--r--app/ServiceProvider/AuthenticationProvider.php143
-rw-r--r--app/ServiceProvider/ClassProvider.php108
-rw-r--r--app/ServiceProvider/DatabaseProvider.php2
-rw-r--r--app/ServiceProvider/EventDispatcherProvider.php5
-rw-r--r--app/ServiceProvider/ExternalLinkProvider.php34
-rw-r--r--app/ServiceProvider/GroupProvider.php40
-rw-r--r--app/ServiceProvider/LoggingProvider.php2
-rw-r--r--app/ServiceProvider/NotificationProvider.php45
-rw-r--r--app/ServiceProvider/PluginProvider.php31
-rw-r--r--app/ServiceProvider/RouteProvider.php201
-rw-r--r--app/ServiceProvider/SessionProvider.php42
-rw-r--r--app/Subscriber/AuthSubscriber.php95
-rw-r--r--app/Subscriber/BaseSubscriber.php40
-rw-r--r--app/Subscriber/BootstrapSubscriber.php29
-rw-r--r--app/Subscriber/NotificationSubscriber.php49
-rw-r--r--app/Subscriber/ProjectDailySummarySubscriber.php15
-rw-r--r--app/Subscriber/ProjectModificationDateSubscriber.php21
-rw-r--r--app/Subscriber/RecurringTaskSubscriber.php14
-rw-r--r--app/Subscriber/SubtaskTimeTrackingSubscriber.php8
-rw-r--r--app/Subscriber/TaskMovedDateSubscriber.php25
-rw-r--r--app/Subscriber/TransitionSubscriber.php6
-rw-r--r--app/Template/action/event.php2
-rw-r--r--app/Template/action/index.php36
-rw-r--r--app/Template/action/params.php15
-rw-r--r--app/Template/action/remove.php2
-rw-r--r--app/Template/activity/project.php2
-rw-r--r--app/Template/analytic/compare_hours.php62
-rw-r--r--app/Template/analytic/layout.php7
-rw-r--r--app/Template/analytic/sidebar.php22
-rw-r--r--app/Template/app/filters_helper.php8
-rw-r--r--app/Template/app/layout.php20
-rw-r--r--app/Template/app/notifications.php2
-rw-r--r--app/Template/app/overview.php11
-rw-r--r--app/Template/app/projects.php4
-rw-r--r--app/Template/app/sidebar.php16
-rw-r--r--app/Template/app/subtasks.php8
-rw-r--r--app/Template/app/tasks.php8
-rw-r--r--app/Template/auth/index.php28
-rw-r--r--app/Template/board/popover_assignee.php29
-rw-r--r--app/Template/board/popover_category.php29
-rw-r--r--app/Template/board/popover_close_all_tasks_column.php18
-rw-r--r--app/Template/board/table_column.php28
-rw-r--r--app/Template/board/table_swimlane.php2
-rw-r--r--app/Template/board/table_tasks.php4
-rw-r--r--app/Template/board/task_footer.php24
-rw-r--r--app/Template/board/task_menu.php11
-rw-r--r--app/Template/board/task_private.php42
-rw-r--r--app/Template/board/task_public.php1
-rw-r--r--app/Template/board/tooltip_comments.php2
-rw-r--r--app/Template/board/tooltip_external_links.php20
-rw-r--r--app/Template/board/tooltip_files.php4
-rw-r--r--app/Template/board/tooltip_subtasks.php17
-rw-r--r--app/Template/board/tooltip_tasklinks.php37
-rw-r--r--app/Template/board/view_private.php2
-rw-r--r--app/Template/calendar/show.php4
-rw-r--r--app/Template/category/edit.php6
-rw-r--r--app/Template/category/index.php9
-rw-r--r--app/Template/category/remove.php2
-rw-r--r--app/Template/column/create.php41
-rw-r--r--app/Template/column/edit.php6
-rw-r--r--app/Template/column/index.php107
-rw-r--r--app/Template/column/remove.php2
-rw-r--r--app/Template/comment/create.php22
-rw-r--r--app/Template/comment/edit.php6
-rw-r--r--app/Template/comment/forbidden.php7
-rw-r--r--app/Template/comment/remove.php2
-rw-r--r--app/Template/comment/show.php8
-rw-r--r--app/Template/config/about.php1
-rw-r--r--app/Template/config/application.php24
-rw-r--r--app/Template/config/board.php10
-rw-r--r--app/Template/config/integrations.php20
-rw-r--r--app/Template/config/layout.php4
-rw-r--r--app/Template/config/project.php9
-rw-r--r--app/Template/config/sidebar.php27
-rw-r--r--app/Template/currency/index.php10
-rw-r--r--app/Template/custom_filter/add.php4
-rw-r--r--app/Template/custom_filter/edit.php10
-rw-r--r--app/Template/custom_filter/index.php21
-rw-r--r--app/Template/custom_filter/remove.php17
-rw-r--r--app/Template/event/events.php2
-rw-r--r--app/Template/event/task_file_create.php11
-rw-r--r--app/Template/export/sidebar.php2
-rw-r--r--app/Template/export/subtasks.php8
-rw-r--r--app/Template/export/summary.php8
-rw-r--r--app/Template/export/tasks.php8
-rw-r--r--app/Template/export/transitions.php8
-rw-r--r--app/Template/file/new.php14
-rw-r--r--app/Template/file/open.php6
-rw-r--r--app/Template/file/show.php56
-rw-r--r--app/Template/file_viewer/show.php14
-rw-r--r--app/Template/gantt/project.php2
-rw-r--r--app/Template/gantt/projects.php8
-rw-r--r--app/Template/gantt/task_creation.php27
-rw-r--r--app/Template/group/associate.php25
-rw-r--r--app/Template/group/create.php19
-rw-r--r--app/Template/group/dissociate.php17
-rw-r--r--app/Template/group/edit.php22
-rw-r--r--app/Template/group/index.php46
-rw-r--r--app/Template/group/remove.php17
-rw-r--r--app/Template/group/users.php42
-rw-r--r--app/Template/header.php80
-rw-r--r--app/Template/layout.php2
-rw-r--r--app/Template/listing/show.php11
-rw-r--r--app/Template/notification/comment_user_mention.php7
-rw-r--r--app/Template/notification/task_create.php4
-rw-r--r--app/Template/notification/task_file_create.php (renamed from app/Template/notification/file_create.php)0
-rw-r--r--app/Template/notification/task_overdue.php2
-rw-r--r--app/Template/notification/task_user_mention.php7
-rw-r--r--app/Template/password_reset/change.php16
-rw-r--r--app/Template/password_reset/create.php17
-rw-r--r--app/Template/password_reset/email.php6
-rw-r--r--app/Template/project/dropdown.php11
-rw-r--r--app/Template/project/duplicate.php6
-rw-r--r--app/Template/project/edit.php50
-rw-r--r--app/Template/project/filters.php101
-rw-r--r--app/Template/project/index.php41
-rw-r--r--app/Template/project/integrations.php24
-rw-r--r--app/Template/project/layout.php2
-rw-r--r--app/Template/project/new.php24
-rw-r--r--app/Template/project/notifications.php2
-rw-r--r--app/Template/project/show.php10
-rw-r--r--app/Template/project/sidebar.php49
-rw-r--r--app/Template/project/users.php82
-rw-r--r--app/Template/project_creation/create.php42
-rw-r--r--app/Template/project_edit/dates.php26
-rw-r--r--app/Template/project_edit/description.php37
-rw-r--r--app/Template/project_edit/general.php36
-rw-r--r--app/Template/project_edit/task_priority.php29
-rw-r--r--app/Template/project_file/create.php33
-rw-r--r--app/Template/project_file/remove.php15
-rw-r--r--app/Template/project_header/dropdown.php34
-rw-r--r--app/Template/project_header/header.php15
-rw-r--r--app/Template/project_header/search.php45
-rw-r--r--app/Template/project_header/views.php24
-rw-r--r--app/Template/project_overview/columns.php8
-rw-r--r--app/Template/project_overview/description.php8
-rw-r--r--app/Template/project_overview/files.php98
-rw-r--r--app/Template/project_overview/information.php35
-rw-r--r--app/Template/project_overview/show.php16
-rw-r--r--app/Template/project_permission/index.php141
-rw-r--r--app/Template/project_user/layout.php11
-rw-r--r--app/Template/project_user/sidebar.php10
-rw-r--r--app/Template/project_user/tasks.php4
-rw-r--r--app/Template/project_user/tooltip_users.php14
-rw-r--r--app/Template/search/index.php5
-rw-r--r--app/Template/search/results.php2
-rw-r--r--app/Template/subtask/create.php19
-rw-r--r--app/Template/subtask/edit.php23
-rw-r--r--app/Template/subtask/icons.php7
-rw-r--r--app/Template/subtask/menu.php11
-rw-r--r--app/Template/subtask/remove.php2
-rw-r--r--app/Template/subtask/show.php94
-rw-r--r--app/Template/subtask/table.php71
-rw-r--r--app/Template/subtask_restriction/popover.php (renamed from app/Template/subtask/restriction_change_status.php)6
-rw-r--r--app/Template/swimlane/create.php37
-rw-r--r--app/Template/swimlane/edit.php6
-rw-r--r--app/Template/swimlane/edit_default.php18
-rw-r--r--app/Template/swimlane/index.php87
-rw-r--r--app/Template/swimlane/remove.php2
-rw-r--r--app/Template/swimlane/table.php110
-rw-r--r--app/Template/task/changes.php4
-rw-r--r--app/Template/task/comments.php6
-rw-r--r--app/Template/task/details.php231
-rw-r--r--app/Template/task/dropdown.php60
-rw-r--r--app/Template/task/layout.php11
-rw-r--r--app/Template/task/menu.php78
-rw-r--r--app/Template/task/public.php11
-rw-r--r--app/Template/task/remove.php2
-rw-r--r--app/Template/task/show.php39
-rw-r--r--app/Template/task/sidebar.php69
-rw-r--r--app/Template/task/time_tracking_details.php6
-rw-r--r--app/Template/task/transitions.php2
-rw-r--r--app/Template/task_creation/form.php59
-rw-r--r--app/Template/task_duplication/copy.php6
-rw-r--r--app/Template/task_duplication/duplicate.php2
-rw-r--r--app/Template/task_duplication/move.php6
-rw-r--r--app/Template/task_external_link/create.php13
-rw-r--r--app/Template/task_external_link/edit.php13
-rw-r--r--app/Template/task_external_link/find.php28
-rw-r--r--app/Template/task_external_link/form.php13
-rw-r--r--app/Template/task_external_link/remove.php15
-rw-r--r--app/Template/task_external_link/show.php50
-rw-r--r--app/Template/task_file/create.php33
-rw-r--r--app/Template/task_file/remove.php (renamed from app/Template/file/remove.php)4
-rw-r--r--app/Template/task_file/screenshot.php (renamed from app/Template/file/screenshot.php)2
-rw-r--r--app/Template/task_file/show.php90
-rw-r--r--app/Template/task_modification/edit_description.php20
-rw-r--r--app/Template/task_modification/edit_task.php58
-rw-r--r--app/Template/task_modification/edit_time.php20
-rw-r--r--app/Template/task_recurrence/edit.php (renamed from app/Template/task_modification/edit_recurrence.php)8
-rw-r--r--app/Template/task_recurrence/info.php (renamed from app/Template/task/recurring_info.php)0
-rw-r--r--app/Template/task_status/close.php2
-rw-r--r--app/Template/task_status/open.php2
-rw-r--r--app/Template/tasklink/create.php12
-rw-r--r--app/Template/tasklink/edit.php4
-rw-r--r--app/Template/tasklink/show.php37
-rw-r--r--app/Template/twofactor/index.php36
-rw-r--r--app/Template/twofactor/show.php31
-rw-r--r--app/Template/user/authentication.php9
-rw-r--r--app/Template/user/create_local.php23
-rw-r--r--app/Template/user/create_remote.php26
-rw-r--r--app/Template/user/dropdown.php27
-rw-r--r--app/Template/user/edit.php14
-rw-r--r--app/Template/user/external.php52
-rw-r--r--app/Template/user/index.php40
-rw-r--r--app/Template/user/last.php2
-rw-r--r--app/Template/user/layout.php4
-rw-r--r--app/Template/user/password.php8
-rw-r--r--app/Template/user/password_reset.php26
-rw-r--r--app/Template/user/profile.php8
-rw-r--r--app/Template/user/sessions.php6
-rw-r--r--app/Template/user/show.php3
-rw-r--r--app/Template/user/sidebar.php56
-rw-r--r--app/Template/user/timesheet.php4
-rw-r--r--app/Template/user_import/step1.php2
-rw-r--r--app/Template/user_status/disable.php13
-rw-r--r--app/Template/user_status/enable.php13
-rw-r--r--app/Template/user_status/remove.php (renamed from app/Template/user/remove.php)6
-rw-r--r--app/User/DatabaseUserProvider.php143
-rw-r--r--app/User/LdapUserProvider.php206
-rw-r--r--app/User/OAuthUserProvider.php140
-rw-r--r--app/User/ReverseProxyUserProvider.php147
-rw-r--r--app/Validator/ActionValidator.php38
-rw-r--r--app/Validator/AuthValidator.php119
-rw-r--r--app/Validator/Base.php54
-rw-r--r--app/Validator/CategoryValidator.php74
-rw-r--r--app/Validator/ColumnValidator.php75
-rw-r--r--app/Validator/CommentValidator.php74
-rw-r--r--app/Validator/CurrencyValidator.php36
-rw-r--r--app/Validator/CustomFilterValidator.php74
-rw-r--r--app/Validator/ExternalLinkValidator.php76
-rw-r--r--app/Validator/GroupValidator.php71
-rw-r--r--app/Validator/LinkValidator.php59
-rw-r--r--app/Validator/PasswordResetValidator.php92
-rw-r--r--app/Validator/ProjectValidator.php86
-rw-r--r--app/Validator/SubtaskValidator.php101
-rw-r--r--app/Validator/SwimlaneValidator.php96
-rw-r--r--app/Validator/TaskLinkValidator.php71
-rw-r--r--app/Validator/TaskValidator.php (renamed from app/Model/TaskValidator.php)11
-rw-r--r--app/Validator/UserValidator.php128
-rw-r--r--app/check_setup.php27
-rw-r--r--app/common.php21
-rw-r--r--app/constants.php53
-rw-r--r--app/functions.php10
-rw-r--r--app/routes.php117
-rw-r--r--assets/css/app.css6
-rw-r--r--assets/css/print.css6
-rw-r--r--assets/css/src/base.css5
-rw-r--r--assets/css/src/board.css20
-rw-r--r--assets/css/src/dropdown.css35
-rw-r--r--assets/css/src/files.css56
-rw-r--r--assets/css/src/filters.css83
-rw-r--r--assets/css/src/form.css26
-rw-r--r--assets/css/src/header.css51
-rw-r--r--assets/css/src/listing.css2
-rw-r--r--assets/css/src/markdown.css10
-rw-r--r--assets/css/src/project.css42
-rw-r--r--assets/css/src/responsive.css25
-rw-r--r--assets/css/src/screenshot.css19
-rw-r--r--assets/css/src/sidebar.css112
-rw-r--r--assets/css/src/table.css38
-rw-r--r--assets/css/src/task.css151
-rw-r--r--assets/css/src/tooltip.css19
-rw-r--r--assets/css/src/upload.css39
-rw-r--r--assets/css/src/views.css34
-rw-r--r--assets/css/vendor/font-awesome.min.css4
-rw-r--r--assets/fonts/FontAwesome.otfbin106260 -> 109688 bytes
-rw-r--r--assets/fonts/fontawesome-webfont.eotbin68875 -> 70807 bytes
-rw-r--r--assets/fonts/fontawesome-webfont.svg63
-rw-r--r--assets/fonts/fontawesome-webfont.ttfbin138204 -> 142072 bytes
-rw-r--r--assets/fonts/fontawesome-webfont.woffbin81284 -> 83588 bytes
-rw-r--r--assets/fonts/fontawesome-webfont.woff2bin64464 -> 66624 bytes
-rw-r--r--assets/img/gitlab-icon.pngbin620 -> 0 bytes
-rw-r--r--assets/js/app.js1229
-rw-r--r--assets/js/src/App.js69
-rw-r--r--assets/js/src/Board.js74
-rw-r--r--assets/js/src/Column.js59
-rw-r--r--assets/js/src/CompareHoursColumnChart.js39
-rw-r--r--assets/js/src/Dropdown.js41
-rw-r--r--assets/js/src/FileUpload.js124
-rw-r--r--assets/js/src/Gantt.js34
-rw-r--r--assets/js/src/Popover.js52
-rw-r--r--assets/js/src/Project.js28
-rw-r--r--assets/js/src/Router.js1
-rw-r--r--assets/js/src/Search.js9
-rw-r--r--assets/js/src/Sidebar.js25
-rw-r--r--assets/js/src/Subtask.js94
-rw-r--r--assets/js/src/Swimlane.js56
-rw-r--r--assets/js/src/Task.js43
-rw-r--r--assets/js/src/Tooltip.js15
-rw-r--r--assets/js/vendor/jquery.textcomplete.js1227
-rw-r--r--composer.json22
-rw-r--r--composer.lock327
-rw-r--r--config.default.php122
-rw-r--r--doc/2fa.markdown12
-rw-r--r--doc/analytics-tasks.markdown6
-rw-r--r--doc/analytics.markdown31
-rw-r--r--doc/api-action-procedures.markdown245
-rw-r--r--doc/api-application-procedures.markdown291
-rw-r--r--doc/api-authentication.markdown66
-rw-r--r--doc/api-board-procedures.markdown158
-rw-r--r--doc/api-category-procedures.markdown172
-rw-r--r--doc/api-column-procedures.markdown229
-rw-r--r--doc/api-comment-procedures.markdown182
-rw-r--r--doc/api-examples.markdown152
-rw-r--r--doc/api-file-procedures.markdown217
-rw-r--r--doc/api-group-member-procedures.markdown152
-rw-r--r--doc/api-group-procedures.markdown174
-rw-r--r--doc/api-json-rpc.markdown4530
-rw-r--r--doc/api-link-procedures.markdown470
-rw-r--r--doc/api-me-procedures.markdown385
-rw-r--r--doc/api-project-permission-procedures.markdown274
-rw-r--r--doc/api-project-procedures.markdown411
-rw-r--r--doc/api-subtask-procedures.markdown194
-rw-r--r--doc/api-swimlane-procedures.markdown438
-rw-r--r--doc/api-task-procedures.markdown636
-rw-r--r--doc/api-user-procedures.markdown357
-rw-r--r--doc/application-configuration.markdown12
-rw-r--r--doc/automatic-actions.markdown100
-rw-r--r--doc/bitbucket-webhooks.markdown55
-rw-r--r--doc/board-collapsed-expanded.markdown2
-rw-r--r--doc/board-configuration.markdown6
-rw-r--r--doc/board-horizontal-scrolling-and-compact-view.markdown4
-rw-r--r--doc/board-show-hide-columns.markdown2
-rw-r--r--doc/bruteforce-protection.markdown10
-rw-r--r--doc/calendar-configuration.markdown12
-rw-r--r--doc/calendar.markdown8
-rw-r--r--doc/centos-installation.markdown4
-rw-r--r--doc/cli.markdown29
-rw-r--r--doc/closing-tasks.markdown10
-rw-r--r--doc/coding-standards.markdown2
-rw-r--r--doc/config.markdown107
-rw-r--r--doc/contributing.markdown15
-rw-r--r--doc/create-tasks-by-email.markdown23
-rw-r--r--doc/creating-projects.markdown6
-rw-r--r--doc/creating-tasks.markdown10
-rw-r--r--doc/cronjob.markdown32
-rw-r--r--doc/currency-rate.markdown2
-rw-r--r--doc/custom-filters.markdown8
-rw-r--r--doc/debian-installation.markdown2
-rw-r--r--doc/docker.markdown45
-rw-r--r--doc/duplicate-move-tasks.markdown4
-rw-r--r--doc/editing-projects.markdown6
-rw-r--r--doc/email-configuration.markdown10
-rw-r--r--doc/faq.markdown52
-rw-r--r--doc/fr/analytics-tasks.markdown4
-rw-r--r--doc/fr/duplicate-move-tasks.markdown2
-rw-r--r--doc/fr/notifications.markdown8
-rw-r--r--doc/fr/recurring-tasks.markdown2
-rw-r--r--doc/fr/time-tracking.markdown2
-rw-r--r--doc/fr/transitions.markdown2
-rw-r--r--doc/freebsd-installation.markdown7
-rw-r--r--doc/gantt-chart-projects.markdown6
-rw-r--r--doc/gantt-chart-tasks.markdown4
-rw-r--r--doc/github-authentication.markdown80
-rw-r--r--doc/github-webhooks.markdown99
-rw-r--r--doc/gitlab-authentication.markdown83
-rw-r--r--doc/gitlab-webhooks.markdown65
-rw-r--r--doc/google-authentication.markdown64
-rw-r--r--doc/groups.markdown17
-rw-r--r--doc/heroku.markdown5
-rw-r--r--doc/ical.markdown22
-rw-r--r--doc/index.markdown23
-rw-r--r--doc/installation.markdown15
-rw-r--r--doc/kanban-vs-todo-and-scrum.markdown4
-rw-r--r--doc/keyboard-shortcuts.markdown3
-rw-r--r--doc/ldap-authentication.markdown144
-rw-r--r--doc/ldap-group-sync.markdown48
-rw-r--r--doc/ldap-parameters.markdown33
-rw-r--r--doc/link-labels.markdown2
-rw-r--r--doc/mysql-configuration.markdown4
-rw-r--r--doc/nginx-ssl-php-fpm.markdown238
-rw-r--r--doc/nice-urls.markdown47
-rw-r--r--doc/notifications.markdown6
-rw-r--r--doc/plugin-authentication-architecture.markdown99
-rw-r--r--doc/plugin-authentication.markdown40
-rw-r--r--doc/plugin-authorization-architecture.markdown39
-rw-r--r--doc/plugin-automatic-actions.markdown60
-rw-r--r--doc/plugin-events.markdown27
-rw-r--r--doc/plugin-external-link.markdown78
-rw-r--r--doc/plugin-group-provider.markdown55
-rw-r--r--doc/plugin-hooks.markdown82
-rw-r--r--doc/plugin-ldap-client.markdown99
-rw-r--r--doc/plugin-mail-transports.markdown6
-rw-r--r--doc/plugin-metadata.markdown9
-rw-r--r--doc/plugin-notifications.markdown4
-rw-r--r--doc/plugin-overrides.markdown2
-rw-r--r--doc/plugin-registration.markdown94
-rw-r--r--doc/plugin-routes.markdown85
-rw-r--r--doc/plugin-schema-migrations.markdown5
-rw-r--r--doc/plugins.markdown15
-rw-r--r--doc/postgresql-configuration.markdown8
-rw-r--r--doc/project-configuration.markdown4
-rw-r--r--doc/project-permissions.markdown54
-rw-r--r--doc/project-types.markdown14
-rw-r--r--doc/project-views.markdown22
-rw-r--r--doc/recommended-configuration.markdown36
-rw-r--r--doc/recurring-tasks.markdown4
-rw-r--r--doc/requirements.markdown103
-rw-r--r--doc/reverse-proxy-authentication.markdown12
-rw-r--r--doc/roles.markdown25
-rw-r--r--doc/rss.markdown4
-rw-r--r--doc/screenshots.markdown6
-rw-r--r--doc/screenshots/groups-management.pngbin0 -> 17922 bytes
-rw-r--r--doc/screenshots/mention-autocomplete.pngbin0 -> 3066 bytes
-rw-r--r--doc/screenshots/project-permissions.pngbin0 -> 64960 bytes
-rw-r--r--doc/search.markdown44
-rw-r--r--doc/sharing-projects.markdown2
-rw-r--r--doc/subtasks.markdown4
-rw-r--r--doc/suse-installation.markdown14
-rw-r--r--doc/swimlanes.markdown6
-rw-r--r--doc/task-links.markdown2
-rw-r--r--doc/tests.markdown130
-rw-r--r--doc/time-tracking.markdown4
-rw-r--r--doc/transitions.markdown4
-rw-r--r--doc/translations.markdown23
-rw-r--r--doc/ubuntu-installation.markdown10
-rw-r--r--doc/update.markdown35
-rw-r--r--doc/usage-examples.markdown2
-rw-r--r--doc/user-management.markdown54
-rw-r--r--doc/user-mentions.markdown17
-rw-r--r--doc/user-types.markdown14
-rw-r--r--doc/vagrant.markdown4
-rw-r--r--doc/webhooks.markdown26
-rw-r--r--doc/what-is-kanban.markdown10
-rw-r--r--doc/windows-apache-installation.markdown14
-rw-r--r--doc/windows-iis-installation.markdown13
-rw-r--r--index.php2
-rw-r--r--issue_template.md24
-rw-r--r--jsonrpc.php6
-rwxr-xr-xkanboard6
-rw-r--r--tests/integration.mysql.xml (renamed from tests/functionals.mysql.xml)2
-rw-r--r--tests/integration.postgres.xml (renamed from tests/functionals.postgres.xml)2
-rw-r--r--tests/integration.sqlite.xml (renamed from tests/functionals.sqlite.xml)2
-rw-r--r--tests/integration/ApiTest.php (renamed from tests/functionals/ApiTest.php)263
-rw-r--r--tests/integration/AppTest.php34
-rw-r--r--tests/integration/Base.php62
-rw-r--r--tests/integration/BoardTest.php21
-rw-r--r--tests/integration/ColumnTest.php65
-rw-r--r--tests/integration/GroupMemberTest.php39
-rw-r--r--tests/integration/GroupTest.php48
-rw-r--r--tests/integration/MeTest.php (renamed from tests/functionals/UserApiTest.php)57
-rw-r--r--tests/integration/ProjectPermissionTest.php64
-rw-r--r--tests/integration/SwimlaneTest.php103
-rw-r--r--tests/integration/TaskTest.php96
-rw-r--r--tests/integration/UserTest.php18
-rw-r--r--tests/units/Action/BaseActionTest.php144
-rw-r--r--tests/units/Action/CommentCreationMoveTaskColumnTest.php58
-rw-r--r--tests/units/Action/CommentCreationTest.php165
-rw-r--r--tests/units/Action/TaskAssignCategoryColorTest.php60
-rw-r--r--tests/units/Action/TaskAssignCategoryLabelTest.php85
-rw-r--r--tests/units/Action/TaskAssignCategoryLinkTest.php97
-rw-r--r--tests/units/Action/TaskAssignColorCategoryTest.php90
-rw-r--r--tests/units/Action/TaskAssignColorColumnTest.php55
-rw-r--r--tests/units/Action/TaskAssignColorLinkTest.php67
-rw-r--r--tests/units/Action/TaskAssignColorUserTest.php78
-rw-r--r--tests/units/Action/TaskAssignCurrentUserColumnTest.php75
-rw-r--r--tests/units/Action/TaskAssignCurrentUserTest.php77
-rw-r--r--tests/units/Action/TaskAssignSpecificUserTest.php72
-rw-r--r--tests/units/Action/TaskAssignUserTest.php62
-rw-r--r--tests/units/Action/TaskCloseColumnTest.php53
-rw-r--r--tests/units/Action/TaskCloseNoActivityTest.php43
-rw-r--r--tests/units/Action/TaskCloseTest.php106
-rw-r--r--tests/units/Action/TaskCreationTest.php49
-rw-r--r--tests/units/Action/TaskDuplicateAnotherProjectTest.php95
-rw-r--r--tests/units/Action/TaskEmailNoActivityTest.php103
-rw-r--r--tests/units/Action/TaskEmailTest.php133
-rw-r--r--tests/units/Action/TaskMoveAnotherProjectTest.php90
-rw-r--r--tests/units/Action/TaskMoveColumnAssignedTest.php56
-rw-r--r--tests/units/Action/TaskMoveColumnCategoryChangeTest.php108
-rw-r--r--tests/units/Action/TaskMoveColumnUnAssignedTest.php74
-rw-r--r--tests/units/Action/TaskOpenTest.php51
-rw-r--r--tests/units/Action/TaskUpdateStartDateTest.php54
-rw-r--r--tests/units/Analytic/AverageLeadCycleTimeAnalyticTest.php70
-rw-r--r--tests/units/Analytic/AverageTimeSpentColumnAnalyticTest.php101
-rw-r--r--tests/units/Analytic/EstimatedTimeComparisonAnalyticTest.php99
-rw-r--r--tests/units/Analytic/TaskDistributionAnalyticTest.php62
-rw-r--r--tests/units/Analytic/UserDistributionAnalyticTest.php83
-rw-r--r--tests/units/Auth/DatabaseAuthTest.php62
-rw-r--r--tests/units/Auth/LdapTest.php697
-rw-r--r--tests/units/Auth/ReverseProxyTest.php37
-rw-r--r--tests/units/Auth/TotpAuthTest.php66
-rw-r--r--tests/units/Base.php76
-rw-r--r--tests/units/Core/Action/ActionManagerTest.php196
-rw-r--r--tests/units/Core/CsvTest.php34
-rw-r--r--tests/units/Core/DateParserTest.php182
-rw-r--r--tests/units/Core/Event/EventManagerTest.php18
-rw-r--r--tests/units/Core/ExternalLink/ExternalLinkManagerTest.php120
-rw-r--r--tests/units/Core/Group/GroupManagerTest.php42
-rw-r--r--tests/units/Core/Http/OAuth2Test.php (renamed from tests/units/Core/OAuth2Test.php)32
-rw-r--r--tests/units/Core/Http/RememberMeCookieTest.php108
-rw-r--r--tests/units/Core/Http/RequestTest.php175
-rw-r--r--tests/units/Core/Http/RouteTest.php79
-rw-r--r--tests/units/Core/Http/RouterTest.php203
-rw-r--r--tests/units/Core/Ldap/ClientTest.php220
-rw-r--r--tests/units/Core/Ldap/EntriesTest.php55
-rw-r--r--tests/units/Core/Ldap/EntryTest.php71
-rw-r--r--tests/units/Core/Ldap/LdapGroupTest.php160
-rw-r--r--tests/units/Core/Ldap/LdapUserTest.php379
-rw-r--r--tests/units/Core/Ldap/QueryTest.php160
-rw-r--r--tests/units/Core/LexerTest.php25
-rw-r--r--tests/units/Core/RouterTest.php81
-rw-r--r--tests/units/Core/Security/AccessMapTest.php53
-rw-r--r--tests/units/Core/Security/AuthenticationManagerTest.php150
-rw-r--r--tests/units/Core/Security/AuthorizationTest.php39
-rw-r--r--tests/units/Core/Security/TokenTest.php29
-rw-r--r--tests/units/Core/Session/FlashMessageTest.php23
-rw-r--r--tests/units/Core/Session/SessionStorageTest.php60
-rw-r--r--tests/units/Core/User/GroupSyncTest.php30
-rw-r--r--tests/units/Core/User/UserProfileTest.php63
-rw-r--r--tests/units/Core/User/UserPropertyTest.php60
-rw-r--r--tests/units/Core/User/UserSessionTest.php144
-rw-r--r--tests/units/Core/User/UserSyncTest.php55
-rw-r--r--tests/units/ExternalLink/AttachmentLinkProviderTest.php64
-rw-r--r--tests/units/ExternalLink/AttachmentLinkTest.php18
-rw-r--r--tests/units/ExternalLink/WebLinkProviderTest.php52
-rw-r--r--tests/units/ExternalLink/WebLinkTest.php45
-rw-r--r--tests/units/Group/LdapBackendGroupProviderTest.php16
-rw-r--r--tests/units/Helper/AppHelperTest.php10
-rw-r--r--tests/units/Helper/AssetHelperTest.php1
-rw-r--r--tests/units/Helper/DatetimeHelperTest.php52
-rw-r--r--tests/units/Helper/FileHelperText.php27
-rw-r--r--tests/units/Helper/TaskHelperTest.php32
-rw-r--r--tests/units/Helper/TextHelperTest.php8
-rw-r--r--tests/units/Helper/UrlHelperTest.php80
-rw-r--r--tests/units/Helper/UserHelperTest.php310
-rw-r--r--tests/units/Integration/BitbucketWebhookTest.php398
-rw-r--r--tests/units/Integration/GithubWebhookTest.php459
-rw-r--r--tests/units/Integration/GitlabWebhookTest.php221
-rw-r--r--tests/units/Model/AclTest.php308
-rw-r--r--tests/units/Model/ActionTest.php701
-rw-r--r--tests/units/Model/AuthenticationTest.php39
-rw-r--r--tests/units/Model/BoardTest.php228
-rw-r--r--tests/units/Model/ColumnTest.php236
-rw-r--r--tests/units/Model/CommentTest.php49
-rw-r--r--tests/units/Model/ConfigTest.php145
-rw-r--r--tests/units/Model/CurrencyTest.php53
-rw-r--r--tests/units/Model/CustomFilterTest.php28
-rw-r--r--tests/units/Model/FileTest.php263
-rw-r--r--tests/units/Model/GroupMemberTest.php76
-rw-r--r--tests/units/Model/GroupTest.php60
-rw-r--r--tests/units/Model/LastLoginTest.php43
-rw-r--r--tests/units/Model/LinkTest.php46
-rw-r--r--tests/units/Model/NotificationTest.php4
-rw-r--r--tests/units/Model/PasswordResetTest.php85
-rw-r--r--tests/units/Model/ProjectActivityTest.php17
-rw-r--r--tests/units/Model/ProjectDailyColumnStatsTest.php323
-rw-r--r--tests/units/Model/ProjectDailyStatsTest.php48
-rw-r--r--tests/units/Model/ProjectDuplicationTest.php402
-rw-r--r--tests/units/Model/ProjectFileTest.php311
-rw-r--r--tests/units/Model/ProjectGroupRoleTest.php401
-rw-r--r--tests/units/Model/ProjectMetadataTest.php5
-rw-r--r--tests/units/Model/ProjectPermissionTest.php529
-rw-r--r--tests/units/Model/ProjectTest.php67
-rw-r--r--tests/units/Model/ProjectUserRoleTest.php461
-rw-r--r--tests/units/Model/SubtaskTest.php194
-rw-r--r--tests/units/Model/SubtaskTimeTrackingTest.php4
-rw-r--r--tests/units/Model/SwimlaneTest.php273
-rw-r--r--tests/units/Model/TaskCreationTest.php11
-rw-r--r--tests/units/Model/TaskDuplicationTest.php46
-rw-r--r--tests/units/Model/TaskExportTest.php2
-rw-r--r--tests/units/Model/TaskExternalLinkTest.php123
-rw-r--r--tests/units/Model/TaskFileTest.php446
-rw-r--r--tests/units/Model/TaskFilterTest.php60
-rw-r--r--tests/units/Model/TaskFinderTest.php1
-rw-r--r--tests/units/Model/TaskLinkTest.php49
-rw-r--r--tests/units/Model/TaskMetadataTest.php5
-rw-r--r--tests/units/Model/TaskModificationTest.php19
-rw-r--r--tests/units/Model/TaskMovedDateSubscriberTest.php77
-rw-r--r--tests/units/Model/TaskPermissionTest.php18
-rw-r--r--tests/units/Model/TaskPositionTest.php18
-rw-r--r--tests/units/Model/TaskStatusTest.php37
-rw-r--r--tests/units/Model/TaskTest.php1
-rw-r--r--tests/units/Model/UserLockingTest.php43
-rw-r--r--tests/units/Model/UserMentionTest.php114
-rw-r--r--tests/units/Model/UserMetadataTest.php5
-rw-r--r--tests/units/Model/UserNotificationTest.php62
-rw-r--r--tests/units/Model/UserSessionTest.php32
-rw-r--r--tests/units/Model/UserTest.php103
-rw-r--r--tests/units/Model/UserUnreadNotificationTest.php1
-rw-r--r--tests/units/Notification/MailTest.php5
-rw-r--r--tests/units/Notification/WebhookTest.php88
-rw-r--r--tests/units/User/DatabaseUserProviderTest.php14
-rw-r--r--tests/units/Validator/CommentValidatorTest.php57
-rw-r--r--tests/units/Validator/CurrencyValidatorTest.php27
-rw-r--r--tests/units/Validator/CustomFilterValidatorTest.php40
-rw-r--r--tests/units/Validator/ExternalLinkValidatorTest.php63
-rw-r--r--tests/units/Validator/GroupValidatorTest.php30
-rw-r--r--tests/units/Validator/LinkValidatorTest.php54
-rw-r--r--tests/units/Validator/PasswordResetValidatorTest.php64
-rw-r--r--tests/units/Validator/ProjectValidatorTest.php67
-rw-r--r--tests/units/Validator/TaskLinkValidatorTest.php72
-rw-r--r--tests/units/Validator/UserValidatorTest.php41
-rw-r--r--tests/units/fixtures/bitbucket_comment_created.json147
-rw-r--r--tests/units/fixtures/bitbucket_issue_assigned.json209
-rw-r--r--tests/units/fixtures/bitbucket_issue_closed.json183
-rw-r--r--tests/units/fixtures/bitbucket_issue_opened.json112
-rw-r--r--tests/units/fixtures/bitbucket_issue_reopened.json183
-rw-r--r--tests/units/fixtures/bitbucket_issue_unassigned.json193
-rw-r--r--tests/units/fixtures/bitbucket_push.json182
-rw-r--r--tests/units/fixtures/github_comment_created.json187
-rw-r--r--tests/units/fixtures/github_issue_assigned.json196
-rw-r--r--tests/units/fixtures/github_issue_closed.json159
-rw-r--r--tests/units/fixtures/github_issue_labeled.json170
-rw-r--r--tests/units/fixtures/github_issue_opened.json159
-rw-r--r--tests/units/fixtures/github_issue_reopened.json159
-rw-r--r--tests/units/fixtures/github_issue_unassigned.json178
-rw-r--r--tests/units/fixtures/github_issue_unlabeled.json164
-rw-r--r--tests/units/fixtures/github_push.json165
-rw-r--r--tests/units/fixtures/gitlab_comment_created.json46
-rw-r--r--tests/units/fixtures/gitlab_issue_closed.json25
-rw-r--r--tests/units/fixtures/gitlab_issue_opened.json25
-rw-r--r--tests/units/fixtures/gitlab_push.json44
939 files changed, 52616 insertions, 30950 deletions
diff --git a/.docker/crontab/kanboard b/.docker/crontab/kanboard
new file mode 100644
index 00000000..91ad044e
--- /dev/null
+++ b/.docker/crontab/kanboard
@@ -0,0 +1 @@
+1 0 * * * cd /var/www/kanboard && ./kanboard cronjob >/dev/null 2>&1
diff --git a/.docker/kanboard/config.php b/.docker/kanboard/config.php
new file mode 100644
index 00000000..fa1c5971
--- /dev/null
+++ b/.docker/kanboard/config.php
@@ -0,0 +1,3 @@
+<?php
+
+define('ENABLE_URL_REWRITE', true);
diff --git a/.docker/nginx/nginx.conf b/.docker/nginx/nginx.conf
new file mode 100644
index 00000000..88c532fe
--- /dev/null
+++ b/.docker/nginx/nginx.conf
@@ -0,0 +1,71 @@
+user nginx;
+worker_processes 1;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ tcp_nopush on;
+ tcp_nodelay on;
+ keepalive_timeout 65;
+ server_tokens off;
+ access_log off;
+ error_log /dev/stderr;
+
+ server {
+ listen 80;
+ server_name localhost;
+ index index.php;
+ root /var/www/kanboard;
+
+ location / {
+ try_files $uri $uri/ /index.php$is_args$args;
+ }
+
+ location ~ \.php$ {
+ try_files $uri =404;
+ fastcgi_split_path_info ^(.+\.php)(/.+)$;
+ fastcgi_pass unix:/var/run/php-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_index index.php;
+ include fastcgi_params;
+ }
+
+ location /data {
+ return 404;
+ }
+
+ location ~* ^.+\.(log|sqlite)$ {
+ return 404;
+ }
+
+ location ~ /\.ht {
+ return 404;
+ }
+
+ location ~* ^.+\.(ico|jpg|gif|png|css|js|svg|eot|ttf|woff|woff2|otf)$ {
+ expires 7d;
+ etag on;
+ }
+
+ client_max_body_size 32M;
+ gzip on;
+ gzip_comp_level 3;
+ gzip_disable "msie6";
+ gzip_vary on;
+ gzip_types
+ text/javascript
+ application/javascript
+ application/json
+ text/xml
+ application/xml
+ application/rss+xml
+ text/css
+ text/plain;
+ }
+}
diff --git a/.docker/php/conf.d/local.ini b/.docker/php/conf.d/local.ini
new file mode 100644
index 00000000..12d22cc6
--- /dev/null
+++ b/.docker/php/conf.d/local.ini
@@ -0,0 +1,15 @@
+expose_php = Off
+error_reporting = E_ALL
+display_errors = Off
+log_errors = On
+error_log = syslog
+date.timezone = UTC
+allow_url_fopen = On
+post_max_size = 30M
+upload_max_filesize = 30M
+opcache.max_accelerated_files = 7963
+opcache.validate_timestamps = Off
+opcache.save_comments = 0
+opcache.load_comments = 0
+opcache.fast_shutdown = 1
+opcache.enable_file_override = On \ No newline at end of file
diff --git a/.docker/php/php-fpm.conf b/.docker/php/php-fpm.conf
new file mode 100644
index 00000000..9e7ccd9c
--- /dev/null
+++ b/.docker/php/php-fpm.conf
@@ -0,0 +1,17 @@
+[global]
+error_log = /dev/stderr
+log_level = error
+daemonize = no
+
+[www]
+user = nginx
+group = nginx
+listen.owner = nginx
+listen.group = nginx
+listen = /var/run/php-fpm.sock
+pm = dynamic
+pm.max_children = 20
+pm.start_servers = 1
+pm.min_spare_servers = 1
+pm.max_spare_servers = 3
+pm.max_requests = 2048
diff --git a/.docker/services.d/.s6-svscan/finish b/.docker/services.d/.s6-svscan/finish
new file mode 100755
index 00000000..fc3b1e93
--- /dev/null
+++ b/.docker/services.d/.s6-svscan/finish
@@ -0,0 +1,2 @@
+#!/bin/sh
+/bin/true \ No newline at end of file
diff --git a/.docker/services.d/cron/run b/.docker/services.d/cron/run
new file mode 100755
index 00000000..da378099
--- /dev/null
+++ b/.docker/services.d/cron/run
@@ -0,0 +1,2 @@
+#!/bin/execlineb -P
+crond -f \ No newline at end of file
diff --git a/.docker/services.d/nginx/run b/.docker/services.d/nginx/run
new file mode 100755
index 00000000..40a8b54a
--- /dev/null
+++ b/.docker/services.d/nginx/run
@@ -0,0 +1,2 @@
+#!/bin/execlineb -P
+nginx -g "daemon off;" \ No newline at end of file
diff --git a/.docker/services.d/php/run b/.docker/services.d/php/run
new file mode 100755
index 00000000..e7d2dadd
--- /dev/null
+++ b/.docker/services.d/php/run
@@ -0,0 +1,2 @@
+#!/bin/execlineb -P
+php-fpm -F \ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 707c6447..9370595e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,5 @@ config.php
data/files
data/cache
/vendor
+*.bak
+!.docker/kanboard/config.php
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 30924370..a1492276 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -5,12 +5,15 @@ Original author and main developer: Frédéric Guillot (fguillot)
Contributors:
+- [85pando](https://github.com/85pando)
- Alex Butum
- [Aleix Pol](https://github.com/aleixpol)
- [Ally Raza](https://github.com/alirz23)
+- [Angystardust](https://github.com/angystardust)
- [Anjar Febrianto](https://github.com/Lasut)
- [Ashbike](https://github.com/ashbike)
- [Ashish Kulkarni](https://github.com/ashkulz)
+- [Busfreak](https://github.com/Busfreak)
- [Christian González](https://github.com/nerdoc)
- [Chorgroup](https://github.com/chorgroup)
- Claudio Lobo
@@ -20,11 +23,16 @@ Contributors:
- [Crash5](https://github.com/crash5)
- [Creador30](https://github.com/creador30)
- [Cynthia Pereira](https://github.com/cynthiapereira)
-- [David-Norris](https://github.com/David-Norris)
+- [d4rk5eed](https://github.com/d4rk5eed)
+- [Damian](https://github.com/dromek)
- [Daniel Raknes](https://github.com/danielraknes)
+- [David-Norris](https://github.com/David-Norris)
+- [Dmitry](https://github.com/dmkcv)
+- [Djpadz](https://github.com/djpadz)
- [Draza (bdpsoft)](https://github.com/bdpsoft)
- [Eskiso](https://github.com/eSkiSo)
- [Esteban Monge](https://github.com/EstebanMonge)
+- [Eugene (JohnBat26)](https://github.com/JohnBat26)
- [Fábio Hideki](https://github.com/fabiohxcx)
- [Fabiano Castro Pereira](https://github.com/fabiano-pereira)
- [Federico Lazcano](https://github.com/lazki)
@@ -37,12 +45,14 @@ Contributors:
- [Jan Dittrich](https://github.com/jdittrich)
- [Janne Mäntyharju](https://github.com/JanneMantyharju)
- [Jean-François Magnier](https://github.com/lefakir)
+- [Jeff Guillou](https://github.com/jf-guillou)
- [Jesusaplsoft](https://github.com/jesusaplsoft)
- [Jesús Marín](https://github.com/alu0100502114)
- [Jules Verhaeren](https://github.com/julesverhaeren)
- [Karol J](https://github.com/dzudek)
- [Kiswa](https://github.com/kiswa)
- [Kralo](https://github.com/kralo)
+- [Kolesar](https://github.com/Kolesar)
- [Lars Christian Schou](https://github.com/NegoZiatoR)
- [Lesstat](https://github.com/Lesstat)
- [Levlaz](https://github.com/levlaz)
@@ -54,16 +64,20 @@ Contributors:
- [Maxime](https://github.com/EpocDotFr)
- [Max Kamashev](https://github.com/ukko)
- [mfoucrier](https://github.com/mfoucrier)
+- [Matthew Cillo](https://github.com/peripatetic-sojourner)
- [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)
+- [Muhaimin](https://github.com/infacq)
- [Nala Ginrut](https://github.com/NalaGinrut)
- [Nekohayo](https://github.com/nekohayo)
- [Nicolas Lœuillet](https://github.com/nicosomb)
+- [Nick Blackledge](https://github.com/nttict-nick-b)
- [Norcnorc](https://github.com/norcnorc)
- [Nramel](https://github.com/nramel)
- [Null-Kelvin](https://github.com/Null-Kelvin)
+- [Ogün Karakuş](https://github.com/ogunkarakus)
- [Olaf Lessenich](https://github.com/xai)
- [Oliver Bertuch](https://github.com/poikilotherm)
- [Oliver Jakoubek](https://github.com/jakoubek)
@@ -72,6 +86,8 @@ Contributors:
- Paolo Mainieri
- [Pavel Roušar](https://github.com/rousarp)
- [Peller Zoltan](https://github.com/PierP)
+- [Perburn](https://github.com/perburn)
+- [Peripatetic-sojourner](https://github.com/peripatetic-sojourner)
- [Petja Touru](https://github.com/Petja)
- [Pierre-Alexis de Solminihac](https://github.com/pa-de-solminihac)
- [Piotr Zęgota](https://github.com/ZegalPL)
@@ -82,6 +98,7 @@ Contributors:
- [Sebastien Pacilly](https://github.com/spacilly)
- [Sebastian Reese](https://github.com/ReeseSebastian)
- [Semyon Novikov](https://github.com/semka)
+- [StavrosKa](https://github.com/StavrosKa)
- [Sylvain Veyrié](https://github.com/turb)
- [Thomas Lutz](https://github.com/phoen1x)
- [Timo](https://github.com/BlueTeck)
@@ -89,10 +106,12 @@ Contributors:
- [Tomáš Votruba](https://github.com/TomasVotruba)
- [Toomyem](https://github.com/Toomyem)
- [Tony G. Bolaño](https://github.com/tonybolanyo)
+- [Trapulo](https://github.com/Trapulo)
- [Torsten](https://github.com/misterfu)
- [Troloo](https://github.com/troloo)
- [Typz](https://github.com/Typz)
- [Vedovator](https://github.com/vedovator)
+- [Vitaliy S. Orlov](https://github.com/orlov0562)
- [Vladimir Babin](https://github.com/Chiliec)
- [Yannick Ihmels](https://github.com/ihmels)
- [Ybarc](https://github.com/ybarc)
diff --git a/ChangeLog b/ChangeLog
index 87bb3790..953c54ca 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,11 +1,236 @@
+Version 1.0.26 (unreleased)
+--------------
+
+Breaking changes:
+
+* API procedures:
+ - "moveColumnUp" and "moveColumnDown" are replace by "changeColumnPosition"
+ - "moveSwimlaneUp" and "moveSwimlaneDown" are replace by "changeSwimlanePosition"
+
+New features:
+
+* Add drag and drop to change subtasks, swimlanes and columns positions
+* Add file drag and drop and asynchronous upload
+* Enable/Disable users
+* Add setting option to disable private projects
+* Add new config option to disable logout
+
+Improvements:
+
+* Use inline popup to create new columns
+* Improve filter box design
+* Improve image thumbnails and files table
+* Add confirmation inline popup to remove custom filter
+* Increase client_max_body_size value for Nginx
+* Split Board model into multiple classes
+
+Bug fixes:
+
+* Fix PHP notices during creation of first project and in subtasks table
+* Fix filter dropdown not accessible when there are too many items
+* Fix regression: unable to change project in "task move/duplicate to another project"
+
+Version 1.0.25
+--------------
+
+Breaking changes:
+
+* Core functionalities moved to external plugins:
+ - Google Auth: https://github.com/kanboard/plugin-google-auth
+ - Github Auth: https://github.com/kanboard/plugin-github-auth
+ - Gitlab Auth: https://github.com/kanboard/plugin-gitlab-auth
+
+New features:
+
+* When creating a new project, have the possibility to select another project to duplicate
+* Add a "Me" button to assignee form element
+* Add external links for tasks with plugin api
+* Add project owner (Directly Responsible Individual)
+* Add configurable task priority
+* Add Greek translation
+* Add automatic actions to close tasks with no activity
+* Add automatic actions to send an email when there is no activity on a task
+* Regroup all daily background tasks in one command: "cronjob"
+* Add task dropdown menu on listing pages
+
+Improvements:
+
+* New Dockerfile based on Alpine Linux and Nginx/PHP-FPM
+* The date time format can be chosen in application settings
+* Export only open tasks in iCal feed
+* Remove time form on task summary page and move that to task edit form
+* Replace box shadow by a larger border width when a task is recently modified
+* Do not refresh the whole page when changing subtask status
+* Add dropdown menu with inline popup for all task actions
+* Change sidebar style
+* Change task summary layout
+* Use inline popup for subtasks, categories, swimlanes, actions and columns
+* Move homepage menus to the user dropdown
+* Have a new task assigned to the creator by default instead of "no assignee"
+* Show progress for task links in board tooltips
+* Simplify code to handle ajax popover and redirects
+* Simplify layout and templates generation
+* Move task form elements to Task helper
+
+Bug fixes:
+
+* Category label is broken on the board if there's a url in the description
+* Fix pagination on task time tracking page
+
+Version 1.0.24
+--------------
+
+New features:
+
+* Forgot Password
+* Add drop-down menu on each board column title to close all tasks
+* Add Malay language
+* Add new API procedures for groups, roles, project permissions and to move/duplicate tasks to another project
+
+Improvements:
+
+* Avoid to send XHR request when a task has not moved after a drag and drop
+* Set maximum dropzone height when the individual column scrolling is disabled
+* Always show the search box in board selector
+* Replace logout link by a drop-down menu
+* Handle notification for group members attached to a project
+* Return the highest role for a project when a user is member of multiple groups
+* Show in user interface the saving state of the task
+* Add drop-down menu for subtasks, categories, swimlanes, columns, custom filters, task links and groups
+* Add new template hooks
+* Application settings are not cached anymore in the session
+* Do not check board status during task move
+* Move validators to a separate namespace
+* Improve and write unit tests for reports
+* Reduce the number of SQL queries for project daily column stats
+* Remove event subscriber to update date_moved field
+* Make sure that some event subscribers are not executed multiple times
+* Show rendering time of individual templates when debug mode is enabled
+* Make sure that no events are fired if nothing has been modified in the task
+* Make dashboard section title clickable
+* Add unit tests for LastLogin
+
+Bug fixes:
+
+* Automatic action listeners were using the same instance
+* Fix wrong link for category in task footer
+* Unable to set currency rate with Postgres database
+* Avoid automatic actions that change the color to fire subsequent events
+* Unable to unassign a task from the API
+* Revert back previous optimizations of TaskPosition (incompatibility with some environment)
+
+Version 1.0.23
+--------------
+
+Breaking changes:
+
+* Plugin API changes for Automatic Actions
+* Automatic Action to close a task doesn't have the column parameter anymore (use the action "Close a task in a specific column")
+* Action name stored in the database is now the absolute class name
+* Core functionalities moved to external plugins:
+ - Github Webhook: https://github.com/kanboard/plugin-github-webhook
+ - Gitlab Webhook: https://github.com/kanboard/plugin-gitlab-webhook
+ - Bitbucket Webhook: https://github.com/kanboard/plugin-bitbucket-webhook
+
+New features:
+
+* Added support of user mentions (@username)
+* Added report to compare working hours between open and closed tasks
+* Added the possibility to define custom routes from plugins
+* Added new method to remove metadata
+
+Improvements:
+
+* Improve Two-Factor activation and plugin API
+* Improving performance during task position change (SQL queries are 3 times faster than before)
+* Do not show window scrollbars when individual column scrolling is enabled
+* Automatic Actions code improvements and unit tests
+* Increase action name column length in actions table
+
+Bug fixes:
+
+* Fix compatibility issue with FreeBSD for session.hash_function parameter
+* Fix wrong constant name that causes a PHP error in project management section
+* Fix pagination in group members listing
+* Avoid PHP error when enabling LDAP group provider with PHP < 5.5
+
+Version 1.0.22
+--------------
+
+Breaking changes:
+
+* LDAP configuration parameters changes (See documentation)
+* SQL table changes:
+ - "users" table: added new column "role" and removed columns "is_admin" and "is_project_admin"
+ - "project_has_users" table: replaced column "is_owner" with column "role"
+ - Sqlite does not support alter table, old columns still there but unused
+* API procedure changes:
+ - createUser
+ - createLdapUser
+ - updateUser
+ - updateTask
+* Event removed: "session.bootstrap", use "app.boostrap" instead
+
+New features:
+
+* Add pluggable authentication and authorization system (complete rewrite)
+* Add groups (teams/organization)
+* Add LDAP groups synchronization
+* Add project group permissions
+* Add new project role Viewer
+* Add generic LDAP client library
+* Add search query attribute for task link
+* Add the possibility to define API token in config file
+* Add capability to reopen Gitlab issues
+* Try to load config.php from /data if not available
+
+Version 1.0.21
+--------------
+
+Breaking changes:
+
+* Projects with duplicate names are now allowed:
+ - For Postgres and Mysql the unique constraint is removed by database migration
+ - However Sqlite does not support alter table, only new databases will have the unique constraint removed
+
+New features:
+
+* New automatic action: Assign a category based on a link
+* Added Bosnian translation
+
+Improvements:
+
+* Dropdown menu entries are now clickable outside of the html link
+* Improve error handling of plugins
+* Use PHP7 function random_bytes() to generate tokens if available
+* CSV task export show the assignee name in addition to the assignee username
+* Add new hooks for plugins
+* Remove workaround for "INSERT ON DUPLICATE KEY UPDATE..."
+
+Internal code refactoring:
+
+* Rewrite of session management
+* Move some classes to a new namespace Kanboard\Core\Http
+
+Bug fixes:
+
+* Loading cs_CZ locale display the wrong language in datetime picker
+* Datepicker is closed unexpectedly on blur event
+* Fix bug in daily project summary CSV export
+* Fix PHP error when adding a new user with email notification enabled
+* Add missing template for activity stream to show event "file.create"
+* Fix wrong value for PLUGINS_DIR in config.default.php
+* Make CSV export compatible with PHP 5.3
+* Avoid Safari to append .html at the end of downloaded files
+
Version 1.0.20
--------------
Breaking changes:
-- Add namespace Kanboard (update your plugins)
-- Move Mailgun, Sendgrid, Postmark, Slack, Hipchat and Jabber to plugins
-- ReverseProxy authentication check for each request that the username match the user session
+* Add namespace Kanboard (update your plugins)
+* Move Mailgun, Sendgrid, Postmark, Slack, Hipchat and Jabber to plugins
+* ReverseProxy authentication check for each request that the username match the user session
New features:
@@ -23,7 +248,7 @@ Improvements:
Bug fixes:
* People should not see any tasks during a search when they are not associated to a project
-* Avoid to disable the default swimlane during renaming when there is no other activated swimlane
+* Avoid disabling the default swimlane during renaming when there is no other activated swimlane
Version 1.0.19
--------------
@@ -55,15 +280,15 @@ Improvements:
* Offer alternative method to create Mysql and Postgres databases (import sql dump)
* Make sure there is always a trailing slash for application_url
* Do not show the checkbox "Show default swimlane" when there is no active swimlanes
-* Append filters instead of replacing value for users and categories dropdowns
+* Append filters instead of replacing value for users and categories drop-downs
* Do not show empty swimlanes in public view
* Change swimlane layout to save space on the screen
* Add the possibility to set/unset max column height (column scrolling)
-* Show "Open this task" in dropdown menu for closed tasks
+* Show "Open this task" in drop-down menu for closed tasks
* Show assignee on card only when someone is assigned (hide nobody text)
-* Highlight selected item in dropdown menus
+* Highlight selected item in drop-down menus
* Gantt chart: change bar color according to task progress
-* Replace color dropdown by color picker in task forms
+* Replace color drop-down by color picker in task forms
* Creating another task stay in the popover (no full page refresh anymore)
* Avoid scrollbar in Gantt chart for row title on Windows platform
* Remove unnecessary margin for calendar header
@@ -75,14 +300,14 @@ Improvements:
Others:
-* Data directory permissions are not checked anymore
+* Data directory permission are not checked anymore
* Data directory is not mandatory anymore for people that use a remote database and remote object storage
Bug fixes:
-* Fix typo in template that prevent the Gitlab OAuth link to be displayed
+* Fix typo in template that prevents Gitlab OAuth link to be displayed
* Fix Markdown preview links focus
-* Avoid dropdown menu to be truncated inside a column with scrolling
+* Avoid drop-down menu to be truncated inside a column with scrolling
* Deleting subtask doesn't update task time tracking
* Fix Mysql error about gitlab_id when creating remote user
* Fix subtask timer bug (event called recursively)
@@ -100,7 +325,7 @@ New features:
* Add hide/show columns
* Add Gantt chart for projects and tasks
* Add new role "Project Administrator"
-* Add login bruteforce protection with captcha and account lockdown
+* Add login brute force protection with captcha and account lockdown
* Add new api procedures: getDefaultTaskColor(), getDefaultTaskColors() and getColorList()
* Add user api access
* Add config parameter to define session duration
@@ -108,7 +333,7 @@ New features:
* Add start/end date for projects
* Add new automated action to change task color based on the task link
* Add milestone marker in board task
-* Add search in task title when using an integer only input
+* Add search for task title when using an integer only input
* Add Portuguese (European) translation
* Add Norwegian translation
@@ -125,16 +350,16 @@ Improvements:
* Improve sidebar menus
* Add no referrer policy in meta tags
* Run automated unit tests with Sqlite/Mysql/Postgres on Travis-ci
-* Add Makefile and remove the scripts directory
+* Add Makefile and remove the "scripts" directory
Bug fixes:
* Wrong template name for subtasks tooltip due to previous refactoring
* Fix broken url for closed tasks in project view
* Fix permission issue when changing the url manually
-* Fix bug task estimate is reseted when using subtask timer
+* Fix bug task estimate is reset when using subtask timer
* Fix screenshot feature with Firefox 40
-* Fix bug when uploading files with cyrilic characters
+* Fix bug when uploading files with Cyrilic characters
Version 1.0.17
--------------
@@ -148,14 +373,14 @@ New features:
* Added new dashboard layout
* Added new layout for board/calendar/list views
* Added filters helper for search forms
-* Added settings option to disable subtask timer
-* Added settings option to include or exclude closed tasks into CFD
-* Added settings option to define the default task color
+* Added setting option to disable subtask timer
+* Added setting option to include or exclude closed tasks into CFD
+* Added setting option to define the default task color
* Added new config option to disable automatic creation of LDAP accounts
* Added loading icon on board view
* Prompt user when moving or duplicate a task to another project
* Added current values when moving/duplicate a task to another project and add a loading icon
-* Added memory consumption in debug log
+* Added memory consumption to debug log
* Added form to create remote user
* Added edit form for user authentication
* Added config option to hide login form
@@ -166,7 +391,7 @@ New features:
* Added new report: Lead and cycle time for projects
* Added new report: Average time spent into each column
* Added task analytics
-* Added icon to set automatically the start date
+* Added icon to set the start date automatically
* Added datetime picker for start date
Improvements:
@@ -175,8 +400,8 @@ Improvements:
* Display user initials when tasks are in collapsed mode
* Show title in tooltip for collapsed tasks
* Improve alert box fadeout to avoid an empty space
-* Set focus on the dropdown for category popover
-* Make escape keyboard shorcut global
+* Set focus on the drop-down for category popover
+* Make escape keyboard shortcut global
* Check the box remember me by default
* Store redirect login url in session instead of using url parameter
* Update Gitlab webhook
@@ -203,7 +428,7 @@ Translations:
Bug fixes:
-* Screenshot dropdown: unexpected scroll down on the board view and focus lost when clicking on the drop zone
+* Screenshot drop-down: unexpected scroll down on the board view and focus lost when clicking on the drop zone
* No creator when duplicating a task
* Avoid the creation of multiple subtask timer for the same task and user
diff --git a/Dockerfile b/Dockerfile
index c64170a0..5b1ee501 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,33 @@
-FROM ubuntu:14.04
+FROM gliderlabs/alpine:latest
MAINTAINER Frederic Guillot <fred@kanboard.net>
-RUN apt-get update && apt-get install -y apache2 php5 php5-gd php5-sqlite git curl && apt-get clean
-RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf && a2enmod rewrite
-RUN sed -ri 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf
-RUN curl -sS https://getcomposer.org/installer | php -- --filename=/usr/local/bin/composer
-RUN cd /var/www && git clone --depth 1 https://github.com/fguillot/kanboard.git
-RUN cd /var/www/kanboard && composer --prefer-dist --no-dev --optimize-autoloader --quiet install
-RUN rm -rf /var/www/html && mv /var/www/kanboard /var/www/html
-RUN chown -R www-data:www-data /var/www/html/data
+RUN apk-install nginx bash ca-certificates s6 curl \
+ php-fpm php-json php-zlib php-xml php-dom php-ctype php-opcache php-zip \
+ php-pdo php-pdo_mysql php-pdo_sqlite php-pdo_pgsql php-ldap \
+ php-gd php-mcrypt php-openssl php-phar \
+ && curl -sS https://getcomposer.org/installer | php -- --filename=/usr/local/bin/composer
-VOLUME /var/www/html/data
+RUN cd /var/www \
+ && wget https://github.com/fguillot/kanboard/archive/master.zip \
+ && unzip -qq master.zip \
+ && rm -f *.zip \
+ && mv kanboard-master kanboard \
+ && cd /var/www/kanboard && composer --prefer-dist --no-dev --optimize-autoloader --quiet install \
+ && chown -R nginx:nginx /var/www/kanboard \
+ && chown -R nginx:nginx /var/lib/nginx
+
+COPY .docker/services.d /etc/services.d
+COPY .docker/php/conf.d/local.ini /etc/php/conf.d/
+COPY .docker/php/php-fpm.conf /etc/php/
+COPY .docker/nginx/nginx.conf /etc/nginx/
+COPY .docker/kanboard/config.php /var/www/kanboard/
+COPY .docker/kanboard/config.php /var/www/kanboard/
+COPY .docker/crontab/kanboard /var/spool/cron/crontabs/nginx
EXPOSE 80
-ENV APACHE_RUN_USER www-data
-ENV APACHE_RUN_GROUP www-data
-ENV APACHE_LOG_DIR /var/log/apache2
-ENV APACHE_LOCK_DIR /var/lock/apache2
-ENV APACHE_PID_FILE /var/run/apache2.pid
+VOLUME /var/www/kanboard/data
+VOLUME /var/www/kanboard/plugins
-CMD /usr/sbin/apache2ctl -D FOREGROUND
+ENTRYPOINT ["/bin/s6-svscan", "/etc/services.d"]
+CMD []
diff --git a/LICENSE b/LICENSE
index dbe50329..c1efa204 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2014-2015 Frédéric Guillot
+Copyright (c) 2014-2016 Frédéric Guillot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile b/Makefile
index 153e4e64..a62c105a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,12 @@
BUILD_DIR = /tmp
-CSS_APP = $(addprefix assets/css/src/, $(addsuffix .css, base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters gantt))
+CSS_APP = $(addprefix assets/css/src/, $(addsuffix .css, base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown upload filters gantt project files views))
CSS_PRINT = $(addprefix assets/css/src/, $(addsuffix .css, print links table board task comment subtask markdown))
CSS_VENDOR = $(addprefix assets/css/vendor/, $(addsuffix .css, jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min))
-JS_APP = $(addprefix assets/js/src/, $(addsuffix .js, Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane Gantt Task TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart Router))
-JS_VENDOR = $(addprefix assets/js/vendor/, $(addsuffix .js, jquery-1.11.3.min jquery-ui.min jquery-ui-timepicker-addon.min jquery.ui.touch-punch.min chosen.jquery.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min))
-JS_LANG = $(addprefix assets/js/vendor/lang/, $(addsuffix .js, da de es fi fr hu id it ja nl nb pl pt pt-br ru sv sr th tr zh-cn))
+JS_APP = $(addprefix assets/js/src/, $(addsuffix .js, Popover Dropdown Tooltip Markdown Search App Screenshot FileUpload Calendar Board Column Swimlane Gantt Task Project Subtask TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart CompareHoursColumnChart Router))
+JS_VENDOR = $(addprefix assets/js/vendor/, $(addsuffix .js, jquery-1.11.3.min jquery-ui.min jquery-ui-timepicker-addon.min jquery.ui.touch-punch.min chosen.jquery.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min jquery.textcomplete))
+JS_LANG = $(addprefix assets/js/vendor/lang/, $(addsuffix .js, cs da de es el fi fr hu id it ja nl nb pl pt pt-br ru sv sr th tr zh-cn))
all: css js
@@ -62,6 +62,7 @@ archive:
@ rm -rf ${BUILD_DIR}/kanboard/*.markdown
@ rm -rf ${BUILD_DIR}/kanboard/*.lock
@ rm -rf ${BUILD_DIR}/kanboard/*.json
+ @ rm -rf ${BUILD_DIR}/kanboard/.docker
@ cd ${BUILD_DIR}/kanboard && find ./vendor -name doc -type d -exec rm -rf {} +;
@ cd ${BUILD_DIR}/kanboard && find ./vendor -name notes -type d -exec rm -rf {} +;
@ cd ${BUILD_DIR}/kanboard && find ./vendor -name test -type d -exec rm -rf {} +;
@@ -77,6 +78,37 @@ archive:
@ cd ${dst} && if [ -L kanboard-latest.zip ]; then unlink kanboard-latest.zip; ln -s kanboard-${version}.zip kanboard-latest.zip; fi
@ rm -rf ${BUILD_DIR}/kanboard
+test-archive:
+ @ echo "Build archive with tests: version=${version}, destination=${dst}"
+ @ rm -rf ${BUILD_DIR}/kanboard ${BUILD_DIR}/kanboard-*.zip
+ @ cd ${BUILD_DIR} && git clone --depth 1 -q https://github.com/fguillot/kanboard.git
+ @ cd ${BUILD_DIR}/kanboard && composer --prefer-dist --optimize-autoloader --quiet install
+ @ rm -rf ${BUILD_DIR}/kanboard/data/*
+ @ rm -rf ${BUILD_DIR}/kanboard/.git*
+ @ rm -rf ${BUILD_DIR}/kanboard/.*.yml
+ @ rm -rf ${BUILD_DIR}/kanboard/*.md
+ @ rm -rf ${BUILD_DIR}/kanboard/*.markdown
+ @ rm -rf ${BUILD_DIR}/kanboard/Dockerfile
+ @ rm -rf ${BUILD_DIR}/kanboard/.docker
+ @ rm -rf ${BUILD_DIR}/kanboard/Vagrantfile
+ @ rm -rf ${BUILD_DIR}/kanboard/app.json
+ @ rm -rf ${BUILD_DIR}/kanboard/plugins/.gitignore
+ @ cd ${BUILD_DIR}/kanboard && find ./vendor -name notes -type d -exec rm -rf {} +;
+ @ cd ${BUILD_DIR}/kanboard && find ./vendor -name test -type d -exec rm -rf {} +;
+ @ cd ${BUILD_DIR}/kanboard && find ./vendor -name tests -type d -exec rm -rf {} +;
+ @ find ${BUILD_DIR}/kanboard/vendor -name composer.json -delete
+ @ find ${BUILD_DIR}/kanboard/vendor -name phpunit.xml -delete
+ @ find ${BUILD_DIR}/kanboard/vendor -name .travis.yml -delete
+ @ find ${BUILD_DIR}/kanboard/vendor -name README.* -delete
+ @ find ${BUILD_DIR}/kanboard/vendor -name .gitignore -delete
+ @ cd ${BUILD_DIR}/kanboard && sed -i.bak s/master/${version}/g app/constants.php && rm -f app/*.bak
+ @ cd ${BUILD_DIR} && zip -r kanboard-${version}.zip kanboard > /dev/null
+ @ cd ${BUILD_DIR} && mv kanboard-${version}.zip ${dst}
+ @ rm -rf ${BUILD_DIR}/kanboard
+
+test-sqlite-coverage:
+ @ phpunit --coverage-html /tmp/coverage --whitelist app/ -c tests/units.sqlite.xml
+
test-sqlite:
@ phpunit -c tests/units.sqlite.xml
@@ -97,7 +129,7 @@ sql:
@ mysqldump -uroot --quote-names --no-create-info --skip-comments --no-set-names kanboard settings >> app/Schema/Sql/mysql.sql
@ mysqldump -uroot --quote-names --no-create-info --skip-comments --no-set-names kanboard links >> app/Schema/Sql/mysql.sql
- @ php -r "echo 'INSERT INTO users (username, password, is_admin) VALUES (\'admin\', \''.password_hash('admin', PASSWORD_DEFAULT).'\', \'1\');';" | \
+ @ php -r "echo 'INSERT INTO users (username, password, role) VALUES (\'admin\', \''.password_hash('admin', PASSWORD_DEFAULT).'\', \'app-admin\');';" | \
tee -a app/Schema/Sql/postgres.sql app/Schema/Sql/mysql.sql >/dev/null
@ let mysql_version=`echo 'select version from schema_version;' | mysql -N -uroot kanboard` ;\
@@ -106,4 +138,13 @@ sql:
@ let pg_version=`psql -U postgres -A -c 'copy(select version from schema_version) to stdout;' kanboard` ;\
echo "INSERT INTO schema_version VALUES ('$$pg_version');" >> app/Schema/Sql/postgres.sql
+docker-image:
+ @ docker build -t kanboard/kanboard:latest .
+
+docker-push:
+ @ docker push kanboard/kanboard:latest
+
+docker-run:
+ @ docker run -d --name kanboard -p 80:80 -t kanboard/kanboard:latest
+
.PHONY: all
diff --git a/README.md b/README.md
index c56c3ba5..ebfdc767 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,11 @@
Kanboard
========
-Kanboard is a project management software that use the Kanban methodology.
+[![Build Status](https://travis-ci.org/fguillot/kanboard.svg)](https://travis-ci.org/fguillot/kanboard)
+[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fguillot/kanboard/badges/quality-score.png?s=2b6490781608657cc8c43d02285bfafb4f489528)](https://scrutinizer-ci.com/g/fguillot/kanboard/)
+[![SensioLabsInsight](https://insight.sensiolabs.com/projects/5e50750e-fc62-4a1f-b02a-71991123a2a7/mini.png)](https://insight.sensiolabs.com/projects/5e50750e-fc62-4a1f-b02a-71991123a2a7)
+
+Kanboard is a project management software that focus on the Kanban methodology.
Official website: <http://kanboard.net>
@@ -9,41 +13,23 @@ Official website: <http://kanboard.net>
- Multiple boards with the ability to drag and drop tasks
- Open source and self-hosted
- Super simple installation
-- Translated in 22 languages
-- Distributed under [MIT License](LICENSE)
-- [List of features are available on the website](http://kanboard.net/features)
-- [Change Log](ChangeLog)
-
-[![Build Status](https://travis-ci.org/fguillot/kanboard.svg)](https://travis-ci.org/fguillot/kanboard)
-
-[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fguillot/kanboard/badges/quality-score.png?s=2b6490781608657cc8c43d02285bfafb4f489528)](https://scrutinizer-ci.com/g/fguillot/kanboard/)
+- Translated in many languages
+- Distributed under [MIT License](https://github.com/fguillot/kanboard/blob/master/LICENSE)
+- The complete [list of features are available on the website](http://kanboard.net/features)
+- [Change Log](https://github.com/fguillot/kanboard/blob/master/ChangeLog)
+- [Documentation](https://github.com/fguillot/kanboard/blob/master/doc/index.markdown)
[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy)
-Known bugs and feature requests
--------------------------------
-
-- Bug tracker: <https://github.com/fguillot/kanboard/issues>
-
Authors
-------
-- Main developer: Frédéric Guillot (fguillot)
-- [List of contributors](CONTRIBUTORS.md)
-
-Documentation
--------------
-
-- [Read the documentation](doc/index.markdown)
+- Main developer: [Frédéric Guillot](https://github.com/fguillot)
+- [List of contributors](https://github.com/fguillot/kanboard/blob/master/CONTRIBUTORS.md)
-Related projects
-----------------
+Installation and Upgrade
+------------------------
-- [Kanboard API python client by @freekoder](https://github.com/freekoder/kanboard-py)
-- [Kanboard Presenter by David Eberlein](https://github.com/davideberlein/kanboard-presenter)
-- [CSV2Kanboard by @ashbike](https://github.com/ashbike/csv2kanboard)
-- [Kanboard for Yunohost by @mbugeia](https://github.com/mbugeia/kanboard_ynh)
-- [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)
-- [Python client script by @dzudek](https://gist.github.com/fguillot/84c70d4928eb1e0cb374)
+- [Requirements](http://kanboard.net/documentation/requirements)
+- [Installation instructions](http://kanboard.net/documentation/installation)
+- [Upgrade to a new version](http://kanboard.net/documentation/update)
diff --git a/app/Action/Base.php b/app/Action/Base.php
index 4d2d6da6..e8449d0c 100644
--- a/app/Action/Base.php
+++ b/app/Action/Base.php
@@ -3,7 +3,6 @@
namespace Kanboard\Action;
use Kanboard\Event\GenericEvent;
-use Pimple\Container;
/**
* Base class for automatic actions
@@ -14,6 +13,14 @@ use Pimple\Container;
abstract class Base extends \Kanboard\Core\Base
{
/**
+ * Extended events
+ *
+ * @access private
+ * @var array
+ */
+ private $compatibleEvents = array();
+
+ /**
* Flag for called listener
*
* @access private
@@ -27,7 +34,7 @@ abstract class Base extends \Kanboard\Core\Base
* @access private
* @var integer
*/
- private $project_id = 0;
+ private $projectId = 0;
/**
* User parameters
@@ -38,20 +45,25 @@ abstract class Base extends \Kanboard\Core\Base
private $params = array();
/**
- * Attached event name
+ * Get automatic action name
*
- * @access protected
- * @var string
+ * @final
+ * @access public
+ * @return string
*/
- protected $event_name = '';
+ final public function getName()
+ {
+ return '\\'.get_called_class();
+ }
/**
- * Container instance
+ * Get automatic action description
*
- * @access protected
- * @var \Pimple\Container
+ * @abstract
+ * @access public
+ * @return string
*/
- protected $container;
+ abstract public function getDescription();
/**
* Execute the action
@@ -100,30 +112,32 @@ abstract class Base extends \Kanboard\Core\Base
abstract public function hasRequiredCondition(array $data);
/**
- * Constructor
+ * Return class information
*
* @access public
- * @param \Pimple\Container $container Container
- * @param integer $project_id Project id
- * @param string $event_name Attached event name
+ * @return string
*/
- public function __construct(Container $container, $project_id, $event_name)
+ public function __toString()
{
- $this->container = $container;
- $this->project_id = $project_id;
- $this->event_name = $event_name;
- $this->called = false;
+ $params = array();
+
+ foreach ($this->params as $key => $value) {
+ $params[] = $key.'='.var_export($value, true);
+ }
+
+ return $this->getName().'('.implode('|', $params).')';
}
/**
- * Return class information
+ * Set project id
*
* @access public
- * @return string
+ * @return Base
*/
- public function __toString()
+ public function setProjectId($project_id)
{
- return get_called_class();
+ $this->projectId = $project_id;
+ return $this;
}
/**
@@ -134,7 +148,7 @@ abstract class Base extends \Kanboard\Core\Base
*/
public function getProjectId()
{
- return $this->project_id;
+ return $this->projectId;
}
/**
@@ -143,10 +157,12 @@ abstract class Base extends \Kanboard\Core\Base
* @access public
* @param string $name Parameter name
* @param mixed $value Value
+ * @param Base
*/
public function setParam($name, $value)
{
$this->params[$name] = $value;
+ return $this;
}
/**
@@ -154,24 +170,25 @@ abstract class Base extends \Kanboard\Core\Base
*
* @access public
* @param string $name Parameter name
- * @param mixed $default_value Default value
+ * @param mixed $default Default value
* @return mixed
*/
- public function getParam($name, $default_value = null)
+ public function getParam($name, $default = null)
{
- return isset($this->params[$name]) ? $this->params[$name] : $default_value;
+ return isset($this->params[$name]) ? $this->params[$name] : $default;
}
/**
* Check if an action is executable (right project and required parameters)
*
* @access public
- * @param array $data Event data dictionary
- * @return bool True if the action is executable
+ * @param array $data
+ * @param string $eventName
+ * @return bool
*/
- public function isExecutable(array $data)
+ public function isExecutable(array $data, $eventName)
{
- return $this->hasCompatibleEvent() &&
+ return $this->hasCompatibleEvent($eventName) &&
$this->hasRequiredProject($data) &&
$this->hasRequiredParameters($data) &&
$this->hasRequiredCondition($data);
@@ -181,11 +198,12 @@ abstract class Base extends \Kanboard\Core\Base
* Check if the event is compatible with the action
*
* @access public
+ * @param string $eventName
* @return bool
*/
- public function hasCompatibleEvent()
+ public function hasCompatibleEvent($eventName)
{
- return in_array($this->event_name, $this->getCompatibleEvents());
+ return in_array($eventName, $this->getEvents());
}
/**
@@ -197,7 +215,7 @@ abstract class Base extends \Kanboard\Core\Base
*/
public function hasRequiredProject(array $data)
{
- return isset($data['project_id']) && $data['project_id'] == $this->project_id;
+ return isset($data['project_id']) && $data['project_id'] == $this->getProjectId();
}
/**
@@ -222,10 +240,11 @@ abstract class Base extends \Kanboard\Core\Base
* Execute the action
*
* @access public
- * @param \Event\GenericEvent $event Event data dictionary
- * @return bool True if the action was executed or false when not executed
+ * @param \Kanboard\Event\GenericEvent $event
+ * @param string $eventName
+ * @return bool
*/
- public function execute(GenericEvent $event)
+ public function execute(GenericEvent $event, $eventName)
{
// Avoid infinite loop, a listener instance can be called only one time
if ($this->called) {
@@ -233,17 +252,44 @@ abstract class Base extends \Kanboard\Core\Base
}
$data = $event->getAll();
- $result = false;
+ $executable = $this->isExecutable($data, $eventName);
+ $executed = false;
- if ($this->isExecutable($data)) {
+ if ($executable) {
$this->called = true;
- $result = $this->doAction($data);
+ $executed = $this->doAction($data);
}
- if (DEBUG) {
- $this->container['logger']->debug(get_called_class().' => '.($result ? 'true' : 'false'));
+ $this->logger->debug($this.' ['.$eventName.'] => executable='.var_export($executable, true).' exec_success='.var_export($executed, true));
+
+ return $executed;
+ }
+
+ /**
+ * Register a new event for the automatic action
+ *
+ * @access public
+ * @param string $event
+ * @param string $description
+ */
+ public function addEvent($event, $description = '')
+ {
+ if ($description !== '') {
+ $this->eventManager->register($event, $description);
}
- return $result;
+ $this->compatibleEvents[] = $event;
+ return $this;
+ }
+
+ /**
+ * Get all compatible events of an automatic action
+ *
+ * @access public
+ * @return array
+ */
+ public function getEvents()
+ {
+ return array_unique(array_merge($this->getCompatibleEvents(), $this->compatibleEvents));
}
}
diff --git a/app/Action/CommentCreation.php b/app/Action/CommentCreation.php
index 73fedc3b..b91e39e2 100644
--- a/app/Action/CommentCreation.php
+++ b/app/Action/CommentCreation.php
@@ -2,10 +2,6 @@
namespace Kanboard\Action;
-use Kanboard\Integration\BitbucketWebhook;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\GitlabWebhook;
-
/**
* Create automatically a comment from a webhook
*
@@ -15,6 +11,17 @@ use Kanboard\Integration\GitlabWebhook;
class CommentCreation extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Create a comment from an external provider');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -22,14 +29,7 @@ class CommentCreation extends Base
*/
public function getCompatibleEvents()
{
- return array(
- GithubWebhook::EVENT_ISSUE_COMMENT,
- GithubWebhook::EVENT_COMMIT,
- BitbucketWebhook::EVENT_ISSUE_COMMENT,
- BitbucketWebhook::EVENT_COMMIT,
- GitlabWebhook::EVENT_COMMIT,
- GitlabWebhook::EVENT_ISSUE_COMMENT,
- );
+ return array();
}
/**
@@ -67,9 +67,9 @@ class CommentCreation extends Base
{
return (bool) $this->comment->create(array(
'reference' => isset($data['reference']) ? $data['reference'] : '',
- 'comment' => empty($data['comment']) ? $data['commit_comment'] : $data['comment'],
+ 'comment' => $data['comment'],
'task_id' => $data['task_id'],
- 'user_id' => empty($data['user_id']) ? 0 : $data['user_id'],
+ 'user_id' => isset($data['user_id']) && $this->projectPermission->isAssignable($this->getProjectId(), $data['user_id']) ? $data['user_id'] : 0,
));
}
@@ -82,6 +82,6 @@ class CommentCreation extends Base
*/
public function hasRequiredCondition(array $data)
{
- return ! empty($data['comment']) || ! empty($data['commit_comment']);
+ return ! empty($data['comment']);
}
}
diff --git a/app/Action/TaskLogMoveAnotherColumn.php b/app/Action/CommentCreationMoveTaskColumn.php
index a699c4ab..11224d67 100644
--- a/app/Action/TaskLogMoveAnotherColumn.php
+++ b/app/Action/CommentCreationMoveTaskColumn.php
@@ -5,14 +5,25 @@ namespace Kanboard\Action;
use Kanboard\Model\Task;
/**
- * Add a log of the triggering event to the task description.
+ * Add a comment of the triggering event to the task description.
*
* @package action
* @author Oren Ben-Kiki
*/
-class TaskLogMoveAnotherColumn extends Base
+class CommentCreationMoveTaskColumn extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Add a comment log when moving the task between columns');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -60,7 +71,7 @@ class TaskLogMoveAnotherColumn extends Base
return false;
}
- $column = $this->board->getColumn($data['column_id']);
+ $column = $this->column->getById($data['column_id']);
return (bool) $this->comment->create(array(
'comment' => t('Moved to column %s', $column['title']),
diff --git a/app/Action/TaskAssignCategoryColor.php b/app/Action/TaskAssignCategoryColor.php
index ffa1ac2a..f5085cb0 100644
--- a/app/Action/TaskAssignCategoryColor.php
+++ b/app/Action/TaskAssignCategoryColor.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskAssignCategoryColor extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign automatically a category based on a color');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
diff --git a/app/Action/TaskAssignCategoryLabel.php b/app/Action/TaskAssignCategoryLabel.php
index 0ef474b6..95fa116e 100644
--- a/app/Action/TaskAssignCategoryLabel.php
+++ b/app/Action/TaskAssignCategoryLabel.php
@@ -2,8 +2,6 @@
namespace Kanboard\Action;
-use Kanboard\Integration\GithubWebhook;
-
/**
* Set a category automatically according to a label
*
@@ -13,6 +11,17 @@ use Kanboard\Integration\GithubWebhook;
class TaskAssignCategoryLabel extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Change the category based on an external label');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -20,9 +29,7 @@ class TaskAssignCategoryLabel extends Base
*/
public function getCompatibleEvents()
{
- return array(
- GithubWebhook::EVENT_ISSUE_LABEL_CHANGE,
- );
+ return array();
}
/**
@@ -64,7 +71,7 @@ class TaskAssignCategoryLabel extends Base
{
$values = array(
'id' => $data['task_id'],
- 'category_id' => isset($data['category_id']) ? $data['category_id'] : $this->getParam('category_id'),
+ 'category_id' => $this->getParam('category_id'),
);
return $this->taskModification->update($values);
@@ -79,6 +86,6 @@ class TaskAssignCategoryLabel extends Base
*/
public function hasRequiredCondition(array $data)
{
- return $data['label'] == $this->getParam('label');
+ return $data['label'] == $this->getParam('label') && empty($data['category_id']);
}
}
diff --git a/app/Action/TaskAssignCategoryLink.php b/app/Action/TaskAssignCategoryLink.php
new file mode 100644
index 00000000..b39e41b4
--- /dev/null
+++ b/app/Action/TaskAssignCategoryLink.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Kanboard\Action;
+
+use Kanboard\Model\TaskLink;
+
+/**
+ * Set a category automatically according to a task link
+ *
+ * @package action
+ * @author Olivier Maridat
+ * @author Frederic Guillot
+ */
+class TaskAssignCategoryLink extends Base
+{
+ /**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign automatically a category based on a link');
+ }
+
+ /**
+ * Get the list of compatible events
+ *
+ * @access public
+ * @return array
+ */
+ public function getCompatibleEvents()
+ {
+ return array(
+ TaskLink::EVENT_CREATE_UPDATE,
+ );
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'category_id' => t('Category'),
+ 'link_id' => t('Link type'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'link_id',
+ );
+ }
+
+ /**
+ * Execute the action (change the category)
+ *
+ * @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)
+ {
+ $values = array(
+ 'id' => $data['task_id'],
+ 'category_id' => $this->getParam('category_id'),
+ );
+
+ return $this->taskModification->update($values);
+ }
+
+ /**
+ * Check if the event data meet the action condition
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool
+ */
+ public function hasRequiredCondition(array $data)
+ {
+ if ($data['link_id'] == $this->getParam('link_id')) {
+ $task = $this->taskFinder->getById($data['task_id']);
+ return empty($task['category_id']);
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskAssignColorCategory.php b/app/Action/TaskAssignColorCategory.php
index a2332f78..139c24cb 100644
--- a/app/Action/TaskAssignColorCategory.php
+++ b/app/Action/TaskAssignColorCategory.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskAssignColorCategory extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign automatically a color based on a category');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -67,7 +78,7 @@ class TaskAssignColorCategory extends Base
'color_id' => $this->getParam('color_id'),
);
- return $this->taskModification->update($values);
+ return $this->taskModification->update($values, false);
}
/**
diff --git a/app/Action/TaskAssignColorColumn.php b/app/Action/TaskAssignColorColumn.php
index 53140733..92412739 100644
--- a/app/Action/TaskAssignColorColumn.php
+++ b/app/Action/TaskAssignColorColumn.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskAssignColorColumn extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign a color when the task is moved to a specific column');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -68,7 +79,7 @@ class TaskAssignColorColumn extends Base
'color_id' => $this->getParam('color_id'),
);
- return $this->taskModification->update($values);
+ return $this->taskModification->update($values, false);
}
/**
diff --git a/app/Action/TaskAssignColorLink.php b/app/Action/TaskAssignColorLink.php
index 67b2ef62..12ceabb3 100644
--- a/app/Action/TaskAssignColorLink.php
+++ b/app/Action/TaskAssignColorLink.php
@@ -13,6 +13,17 @@ use Kanboard\Model\TaskLink;
class TaskAssignColorLink extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Change task color when using a specific task link');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -67,7 +78,7 @@ class TaskAssignColorLink extends Base
'color_id' => $this->getParam('color_id'),
);
- return $this->taskModification->update($values);
+ return $this->taskModification->update($values, false);
}
/**
diff --git a/app/Action/TaskAssignColorUser.php b/app/Action/TaskAssignColorUser.php
index 6bf02c36..6ec8ce95 100644
--- a/app/Action/TaskAssignColorUser.php
+++ b/app/Action/TaskAssignColorUser.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskAssignColorUser extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign a color to a specific user');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -68,7 +79,7 @@ class TaskAssignColorUser extends Base
'color_id' => $this->getParam('color_id'),
);
- return $this->taskModification->update($values);
+ return $this->taskModification->update($values, false);
}
/**
diff --git a/app/Action/TaskAssignCurrentUser.php b/app/Action/TaskAssignCurrentUser.php
index f34c4f36..192a120c 100644
--- a/app/Action/TaskAssignCurrentUser.php
+++ b/app/Action/TaskAssignCurrentUser.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskAssignCurrentUser extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign the task to the person who does the action');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -22,7 +33,6 @@ class TaskAssignCurrentUser extends Base
{
return array(
Task::EVENT_CREATE,
- Task::EVENT_MOVE_COLUMN,
);
}
@@ -34,9 +44,7 @@ class TaskAssignCurrentUser extends Base
*/
public function getActionRequiredParameters()
{
- return array(
- 'column_id' => t('Column'),
- );
+ return array();
}
/**
@@ -49,7 +57,6 @@ class TaskAssignCurrentUser extends Base
{
return array(
'task_id',
- 'column_id',
);
}
@@ -83,6 +90,6 @@ class TaskAssignCurrentUser extends Base
*/
public function hasRequiredCondition(array $data)
{
- return $data['column_id'] == $this->getParam('column_id');
+ return true;
}
}
diff --git a/app/Action/TaskAssignCurrentUserColumn.php b/app/Action/TaskAssignCurrentUserColumn.php
new file mode 100644
index 00000000..05d08dd3
--- /dev/null
+++ b/app/Action/TaskAssignCurrentUserColumn.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Kanboard\Action;
+
+use Kanboard\Model\Task;
+
+/**
+ * Assign a task to the logged user on column change
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskAssignCurrentUserColumn extends Base
+{
+ /**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign the task to the person who does the action when the column is changed');
+ }
+
+ /**
+ * Get the list of compatible events
+ *
+ * @access public
+ * @return array
+ */
+ public function getCompatibleEvents()
+ {
+ return array(
+ Task::EVENT_MOVE_COLUMN,
+ );
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'column_id' => t('Column'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'column_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @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)
+ {
+ if (! $this->userSession->isLogged()) {
+ return false;
+ }
+
+ $values = array(
+ 'id' => $data['task_id'],
+ 'owner_id' => $this->userSession->getId(),
+ );
+
+ return $this->taskModification->update($values);
+ }
+
+ /**
+ * 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('column_id');
+ }
+}
diff --git a/app/Action/TaskAssignSpecificUser.php b/app/Action/TaskAssignSpecificUser.php
index dfcb281b..2dc3e966 100644
--- a/app/Action/TaskAssignSpecificUser.php
+++ b/app/Action/TaskAssignSpecificUser.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskAssignSpecificUser extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Assign the task to a specific user');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
diff --git a/app/Action/TaskAssignUser.php b/app/Action/TaskAssignUser.php
index a5821729..da54d186 100644
--- a/app/Action/TaskAssignUser.php
+++ b/app/Action/TaskAssignUser.php
@@ -2,9 +2,6 @@
namespace Kanboard\Action;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\BitbucketWebhook;
-
/**
* Assign a task to someone
*
@@ -14,6 +11,17 @@ use Kanboard\Integration\BitbucketWebhook;
class TaskAssignUser extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Change the assignee based on an external username');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -21,10 +29,7 @@ class TaskAssignUser extends Base
*/
public function getCompatibleEvents()
{
- return array(
- GithubWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE,
- BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE,
- );
+ return array();
}
/**
@@ -78,6 +83,6 @@ class TaskAssignUser extends Base
*/
public function hasRequiredCondition(array $data)
{
- return true;
+ return $this->projectPermission->isAssignable($this->getProjectId(), $data['owner_id']);
}
}
diff --git a/app/Action/TaskClose.php b/app/Action/TaskClose.php
index d80bd023..cf91e83e 100644
--- a/app/Action/TaskClose.php
+++ b/app/Action/TaskClose.php
@@ -2,11 +2,6 @@
namespace Kanboard\Action;
-use Kanboard\Integration\GitlabWebhook;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\BitbucketWebhook;
-use Kanboard\Model\Task;
-
/**
* Close automatically a task
*
@@ -16,6 +11,17 @@ use Kanboard\Model\Task;
class TaskClose extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Close a task');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -23,15 +29,7 @@ class TaskClose extends Base
*/
public function getCompatibleEvents()
{
- return array(
- Task::EVENT_MOVE_COLUMN,
- GithubWebhook::EVENT_COMMIT,
- GithubWebhook::EVENT_ISSUE_CLOSED,
- GitlabWebhook::EVENT_COMMIT,
- GitlabWebhook::EVENT_ISSUE_CLOSED,
- BitbucketWebhook::EVENT_COMMIT,
- BitbucketWebhook::EVENT_ISSUE_CLOSED,
- );
+ return array();
}
/**
@@ -42,17 +40,7 @@ class TaskClose extends Base
*/
public function getActionRequiredParameters()
{
- switch ($this->event_name) {
- case GithubWebhook::EVENT_COMMIT:
- case GithubWebhook::EVENT_ISSUE_CLOSED:
- case GitlabWebhook::EVENT_COMMIT:
- case GitlabWebhook::EVENT_ISSUE_CLOSED:
- case BitbucketWebhook::EVENT_COMMIT:
- case BitbucketWebhook::EVENT_ISSUE_CLOSED:
- return array();
- default:
- return array('column_id' => t('Column'));
- }
+ return array();
}
/**
@@ -63,17 +51,7 @@ class TaskClose extends Base
*/
public function getEventRequiredParameters()
{
- switch ($this->event_name) {
- case GithubWebhook::EVENT_COMMIT:
- case GithubWebhook::EVENT_ISSUE_CLOSED:
- case GitlabWebhook::EVENT_COMMIT:
- case GitlabWebhook::EVENT_ISSUE_CLOSED:
- case BitbucketWebhook::EVENT_COMMIT:
- case BitbucketWebhook::EVENT_ISSUE_CLOSED:
- return array('task_id');
- default:
- return array('task_id', 'column_id');
- }
+ return array('task_id');
}
/**
@@ -97,16 +75,6 @@ class TaskClose extends Base
*/
public function hasRequiredCondition(array $data)
{
- switch ($this->event_name) {
- case GithubWebhook::EVENT_COMMIT:
- case GithubWebhook::EVENT_ISSUE_CLOSED:
- case GitlabWebhook::EVENT_COMMIT:
- case GitlabWebhook::EVENT_ISSUE_CLOSED:
- case BitbucketWebhook::EVENT_COMMIT:
- case BitbucketWebhook::EVENT_ISSUE_CLOSED:
- return true;
- default:
- return $data['column_id'] == $this->getParam('column_id');
- }
+ return true;
}
}
diff --git a/app/Action/TaskCloseColumn.php b/app/Action/TaskCloseColumn.php
new file mode 100644
index 00000000..09af3b96
--- /dev/null
+++ b/app/Action/TaskCloseColumn.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Kanboard\Action;
+
+use Kanboard\Model\Task;
+
+/**
+ * Close automatically a task in a specific column
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskCloseColumn extends Base
+{
+ /**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Close a task in a specific column');
+ }
+
+ /**
+ * Get the list of compatible events
+ *
+ * @access public
+ * @return array
+ */
+ public function getCompatibleEvents()
+ {
+ return array(
+ Task::EVENT_MOVE_COLUMN,
+ );
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array('column_id' => t('Column'));
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array('task_id', 'column_id');
+ }
+
+ /**
+ * Execute the action (close the task)
+ *
+ * @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)
+ {
+ return $this->taskStatus->close($data['task_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('column_id');
+ }
+}
diff --git a/app/Action/TaskCloseNoActivity.php b/app/Action/TaskCloseNoActivity.php
new file mode 100644
index 00000000..59f7f56a
--- /dev/null
+++ b/app/Action/TaskCloseNoActivity.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Kanboard\Action;
+
+use Kanboard\Model\Task;
+
+/**
+ * Close automatically a task after when inactive
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskCloseNoActivity extends Base
+{
+ /**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Close a task when there is no activity');
+ }
+
+ /**
+ * Get the list of compatible events
+ *
+ * @access public
+ * @return array
+ */
+ public function getCompatibleEvents()
+ {
+ return array(Task::EVENT_DAILY_CRONJOB);
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'duration' => t('Duration in days')
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array('tasks');
+ }
+
+ /**
+ * Execute the action (close the task)
+ *
+ * @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)
+ {
+ $results = array();
+ $max = $this->getParam('duration') * 86400;
+
+ foreach ($data['tasks'] as $task) {
+ $duration = time() - $task['date_modification'];
+
+ if ($duration > $max) {
+ $results[] = $this->taskStatus->close($task['id']);
+ }
+ }
+
+ return in_array(true, $results, true);
+ }
+
+ /**
+ * 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 count($data['tasks']) > 0;
+ }
+}
diff --git a/app/Action/TaskCreation.php b/app/Action/TaskCreation.php
index af1403f0..290c31e1 100644
--- a/app/Action/TaskCreation.php
+++ b/app/Action/TaskCreation.php
@@ -2,10 +2,6 @@
namespace Kanboard\Action;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\GitlabWebhook;
-use Kanboard\Integration\BitbucketWebhook;
-
/**
* Create automatically a task from a webhook
*
@@ -15,6 +11,17 @@ use Kanboard\Integration\BitbucketWebhook;
class TaskCreation extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Create a task from an external provider');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -22,11 +29,7 @@ class TaskCreation extends Base
*/
public function getCompatibleEvents()
{
- return array(
- GithubWebhook::EVENT_ISSUE_OPENED,
- GitlabWebhook::EVENT_ISSUE_OPENED,
- BitbucketWebhook::EVENT_ISSUE_OPENED,
- );
+ return array();
}
/**
diff --git a/app/Action/TaskDuplicateAnotherProject.php b/app/Action/TaskDuplicateAnotherProject.php
index 1f6684dd..5f05136e 100644
--- a/app/Action/TaskDuplicateAnotherProject.php
+++ b/app/Action/TaskDuplicateAnotherProject.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskDuplicateAnotherProject extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Duplicate the task to another project');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -51,7 +62,6 @@ class TaskDuplicateAnotherProject extends Base
return array(
'task_id',
'column_id',
- 'project_id',
);
}
@@ -64,8 +74,7 @@ class TaskDuplicateAnotherProject extends Base
*/
public function doAction(array $data)
{
- $destination_column_id = $this->board->getFirstColumn($this->getParam('project_id'));
-
+ $destination_column_id = $this->column->getFirstColumnId($this->getParam('project_id'));
return (bool) $this->taskDuplication->duplicateToProject($data['task_id'], $this->getParam('project_id'), null, $destination_column_id);
}
diff --git a/app/Action/TaskEmail.php b/app/Action/TaskEmail.php
index 7fb76c4c..4e0e06a6 100644
--- a/app/Action/TaskEmail.php
+++ b/app/Action/TaskEmail.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskEmail extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Send a task by email to someone');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
diff --git a/app/Action/TaskEmailNoActivity.php b/app/Action/TaskEmailNoActivity.php
new file mode 100644
index 00000000..c5d7a797
--- /dev/null
+++ b/app/Action/TaskEmailNoActivity.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Kanboard\Action;
+
+use Kanboard\Model\Task;
+
+/**
+ * Email a task with no activity
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskEmailNoActivity extends Base
+{
+ /**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Send email when there is no activity on a task');
+ }
+
+ /**
+ * Get the list of compatible events
+ *
+ * @access public
+ * @return array
+ */
+ public function getCompatibleEvents()
+ {
+ return array(
+ Task::EVENT_DAILY_CRONJOB,
+ );
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'user_id' => t('User that will receive the email'),
+ 'subject' => t('Email subject'),
+ 'duration' => t('Duration in days'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array('tasks');
+ }
+
+ /**
+ * 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 count($data['tasks']) > 0;
+ }
+
+ /**
+ * 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)
+ {
+ $results = array();
+ $max = $this->getParam('duration') * 86400;
+ $user = $this->user->getById($this->getParam('user_id'));
+
+ if (! empty($user['email'])) {
+ foreach ($data['tasks'] as $task) {
+ $duration = time() - $task['date_modification'];
+
+ if ($duration > $max) {
+ $results[] = $this->sendEmail($task['id'], $user);
+ }
+ }
+ }
+
+ return in_array(true, $results, true);
+ }
+
+ /**
+ * Send email
+ *
+ * @access private
+ * @param integer $task_id
+ * @param array $user
+ * @return boolean
+ */
+ private function sendEmail($task_id, array $user)
+ {
+ $task = $this->taskFinder->getDetails($task_id);
+
+ $this->emailClient->send(
+ $user['email'],
+ $user['name'] ?: $user['username'],
+ $this->getParam('subject'),
+ $this->template->render('notification/task_create', array('task' => $task, 'application_url' => $this->config->get('application_url')))
+ );
+
+ return true;
+ }
+}
diff --git a/app/Action/TaskMoveAnotherProject.php b/app/Action/TaskMoveAnotherProject.php
index 476e2036..fdff0d8c 100644
--- a/app/Action/TaskMoveAnotherProject.php
+++ b/app/Action/TaskMoveAnotherProject.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskMoveAnotherProject extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Move the task to another project');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
diff --git a/app/Action/TaskMoveColumnAssigned.php b/app/Action/TaskMoveColumnAssigned.php
index 16622ee4..1b23a591 100644
--- a/app/Action/TaskMoveColumnAssigned.php
+++ b/app/Action/TaskMoveColumnAssigned.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskMoveColumnAssigned extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Move the task to another column when assigned to a user');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -51,7 +62,6 @@ class TaskMoveColumnAssigned extends Base
return array(
'task_id',
'column_id',
- 'project_id',
'owner_id'
);
}
diff --git a/app/Action/TaskMoveColumnCategoryChange.php b/app/Action/TaskMoveColumnCategoryChange.php
index 1e12be4a..0f591eda 100644
--- a/app/Action/TaskMoveColumnCategoryChange.php
+++ b/app/Action/TaskMoveColumnCategoryChange.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskMoveColumnCategoryChange extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Move the task to another column when the category is changed');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -50,7 +61,6 @@ class TaskMoveColumnCategoryChange extends Base
return array(
'task_id',
'column_id',
- 'project_id',
'category_id',
);
}
@@ -71,7 +81,8 @@ class TaskMoveColumnCategoryChange extends Base
$data['task_id'],
$this->getParam('dest_column_id'),
$original_task['position'],
- $original_task['swimlane_id']
+ $original_task['swimlane_id'],
+ false
);
}
diff --git a/app/Action/TaskMoveColumnUnAssigned.php b/app/Action/TaskMoveColumnUnAssigned.php
index 617c75a8..99ef9351 100644
--- a/app/Action/TaskMoveColumnUnAssigned.php
+++ b/app/Action/TaskMoveColumnUnAssigned.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskMoveColumnUnAssigned extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Move the task to another column when assignee is cleared');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -51,7 +62,6 @@ class TaskMoveColumnUnAssigned extends Base
return array(
'task_id',
'column_id',
- 'project_id',
'owner_id'
);
}
diff --git a/app/Action/TaskOpen.php b/app/Action/TaskOpen.php
index 2e53efae..ec0f96f7 100644
--- a/app/Action/TaskOpen.php
+++ b/app/Action/TaskOpen.php
@@ -2,9 +2,6 @@
namespace Kanboard\Action;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\BitbucketWebhook;
-
/**
* Open automatically a task
*
@@ -14,6 +11,17 @@ use Kanboard\Integration\BitbucketWebhook;
class TaskOpen extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Open a task');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -21,10 +29,7 @@ class TaskOpen extends Base
*/
public function getCompatibleEvents()
{
- return array(
- GithubWebhook::EVENT_ISSUE_REOPENED,
- BitbucketWebhook::EVENT_ISSUE_REOPENED,
- );
+ return array();
}
/**
diff --git a/app/Action/TaskUpdateStartDate.php b/app/Action/TaskUpdateStartDate.php
index 4cd548af..e5cea01b 100644
--- a/app/Action/TaskUpdateStartDate.php
+++ b/app/Action/TaskUpdateStartDate.php
@@ -13,6 +13,17 @@ use Kanboard\Model\Task;
class TaskUpdateStartDate extends Base
{
/**
+ * Get automatic action description
+ *
+ * @access public
+ * @return string
+ */
+ public function getDescription()
+ {
+ return t('Automatically update the start date');
+ }
+
+ /**
* Get the list of compatible events
*
* @access public
@@ -66,7 +77,7 @@ class TaskUpdateStartDate extends Base
'date_started' => time(),
);
- return $this->taskModification->update($values);
+ return $this->taskModification->update($values, false);
}
/**
diff --git a/app/Analytic/AverageLeadCycleTimeAnalytic.php b/app/Analytic/AverageLeadCycleTimeAnalytic.php
new file mode 100644
index 00000000..fd85f864
--- /dev/null
+++ b/app/Analytic/AverageLeadCycleTimeAnalytic.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Kanboard\Analytic;
+
+use Kanboard\Core\Base;
+use Kanboard\Model\Task;
+
+/**
+ * Average Lead and Cycle Time
+ *
+ * @package analytic
+ * @author Frederic Guillot
+ */
+class AverageLeadCycleTimeAnalytic extends Base
+{
+ /**
+ * Build report
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function build($project_id)
+ {
+ $stats = array(
+ 'count' => 0,
+ 'total_lead_time' => 0,
+ 'total_cycle_time' => 0,
+ 'avg_lead_time' => 0,
+ 'avg_cycle_time' => 0,
+ );
+
+ $tasks = $this->getTasks($project_id);
+
+ foreach ($tasks as &$task) {
+ $stats['count']++;
+ $stats['total_lead_time'] += $this->calculateLeadTime($task);
+ $stats['total_cycle_time'] += $this->calculateCycleTime($task);
+ }
+
+ $stats['avg_lead_time'] = $this->calculateAverage($stats, 'total_lead_time');
+ $stats['avg_cycle_time'] = $this->calculateAverage($stats, 'total_cycle_time');
+
+ return $stats;
+ }
+
+ /**
+ * Calculate average
+ *
+ * @access private
+ * @param array &$stats
+ * @param string $field
+ * @return float
+ */
+ private function calculateAverage(array &$stats, $field)
+ {
+ if ($stats['count'] > 0) {
+ return (int) ($stats[$field] / $stats['count']);
+ }
+
+ return 0;
+ }
+
+ /**
+ * Calculate lead time
+ *
+ * @access private
+ * @param array &$task
+ * @return integer
+ */
+ private function calculateLeadTime(array &$task)
+ {
+ $end = $task['date_completed'] ?: time();
+ $start = $task['date_creation'];
+
+ return $end - $start;
+ }
+
+ /**
+ * Calculate cycle time
+ *
+ * @access private
+ * @param array &$task
+ * @return integer
+ */
+ private function calculateCycleTime(array &$task)
+ {
+ if (empty($task['date_started'])) {
+ return 0;
+ }
+
+ $end = $task['date_completed'] ?: time();
+ $start = $task['date_started'];
+
+ return $end - $start;
+ }
+
+ /**
+ * Get the 1000 last created tasks
+ *
+ * @access private
+ * @return array
+ */
+ private function getTasks($project_id)
+ {
+ return $this->db
+ ->table(Task::TABLE)
+ ->columns('date_completed', 'date_creation', 'date_started')
+ ->eq('project_id', $project_id)
+ ->desc('id')
+ ->limit(1000)
+ ->findAll();
+ }
+}
diff --git a/app/Analytic/AverageTimeSpentColumnAnalytic.php b/app/Analytic/AverageTimeSpentColumnAnalytic.php
new file mode 100644
index 00000000..bef55419
--- /dev/null
+++ b/app/Analytic/AverageTimeSpentColumnAnalytic.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Kanboard\Analytic;
+
+use Kanboard\Core\Base;
+use Kanboard\Model\Task;
+
+/**
+ * Average Time Spent by Column
+ *
+ * @package analytic
+ * @author Frederic Guillot
+ */
+class AverageTimeSpentColumnAnalytic extends Base
+{
+ /**
+ * Build report
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function build($project_id)
+ {
+ $stats = $this->initialize($project_id);
+
+ $this->processTasks($stats, $project_id);
+ $this->calculateAverage($stats);
+
+ return $stats;
+ }
+
+ /**
+ * Initialize default values for each column
+ *
+ * @access private
+ * @param integer $project_id
+ * @return array
+ */
+ private function initialize($project_id)
+ {
+ $stats = array();
+ $columns = $this->column->getList($project_id);
+
+ foreach ($columns as $column_id => $column_title) {
+ $stats[$column_id] = array(
+ 'count' => 0,
+ 'time_spent' => 0,
+ 'average' => 0,
+ 'title' => $column_title,
+ );
+ }
+
+ return $stats;
+ }
+
+ /**
+ * Calculate time spent for each tasks for each columns
+ *
+ * @access private
+ * @param array $stats
+ * @param integer $project_id
+ */
+ private function processTasks(array &$stats, $project_id)
+ {
+ $tasks = $this->getTasks($project_id);
+
+ foreach ($tasks as &$task) {
+ foreach ($this->getTaskTimeByColumns($task) as $column_id => $time_spent) {
+ if (isset($stats[$column_id])) {
+ $stats[$column_id]['count']++;
+ $stats[$column_id]['time_spent'] += $time_spent;
+ }
+ }
+ }
+ }
+
+ /**
+ * Calculate averages
+ *
+ * @access private
+ * @param array $stats
+ */
+ private function calculateAverage(array &$stats)
+ {
+ foreach ($stats as &$column) {
+ $this->calculateColumnAverage($column);
+ }
+ }
+
+ /**
+ * Calculate column average
+ *
+ * @access private
+ * @param array $column
+ */
+ private function calculateColumnAverage(array &$column)
+ {
+ if ($column['count'] > 0) {
+ $column['average'] = (int) ($column['time_spent'] / $column['count']);
+ }
+ }
+
+ /**
+ * Get time spent for each column for a given task
+ *
+ * @access private
+ * @param array $task
+ * @return array
+ */
+ private function getTaskTimeByColumns(array &$task)
+ {
+ $columns = $this->transition->getTimeSpentByTask($task['id']);
+
+ if (! isset($columns[$task['column_id']])) {
+ $columns[$task['column_id']] = 0;
+ }
+
+ $columns[$task['column_id']] += $this->getTaskTimeSpentInCurrentColumn($task);
+
+ return $columns;
+ }
+
+ /**
+ * Calculate time spent of a task in the current column
+ *
+ * @access private
+ * @param array $task
+ */
+ private function getTaskTimeSpentInCurrentColumn(array &$task)
+ {
+ $end = $task['date_completed'] ?: time();
+ return $end - $task['date_moved'];
+ }
+
+ /**
+ * Fetch the last 1000 tasks
+ *
+ * @access private
+ * @param integer $project_id
+ * @return array
+ */
+ private function getTasks($project_id)
+ {
+ return $this->db
+ ->table(Task::TABLE)
+ ->columns('id', 'date_completed', 'date_moved', 'column_id')
+ ->eq('project_id', $project_id)
+ ->desc('id')
+ ->limit(1000)
+ ->findAll();
+ }
+}
diff --git a/app/Analytic/EstimatedTimeComparisonAnalytic.php b/app/Analytic/EstimatedTimeComparisonAnalytic.php
new file mode 100644
index 00000000..490bcd50
--- /dev/null
+++ b/app/Analytic/EstimatedTimeComparisonAnalytic.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Kanboard\Analytic;
+
+use Kanboard\Core\Base;
+use Kanboard\Model\Task;
+
+/**
+ * Estimated/Spent Time Comparison
+ *
+ * @package analytic
+ * @author Frederic Guillot
+ */
+class EstimatedTimeComparisonAnalytic extends Base
+{
+ /**
+ * Build report
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function build($project_id)
+ {
+ $rows = $this->db->table(Task::TABLE)
+ ->columns('SUM(time_estimated) AS time_estimated', 'SUM(time_spent) AS time_spent', 'is_active')
+ ->eq('project_id', $project_id)
+ ->groupBy('is_active')
+ ->findAll();
+
+ $metrics = array(
+ 'open' => array(
+ 'time_spent' => 0,
+ 'time_estimated' => 0,
+ ),
+ 'closed' => array(
+ 'time_spent' => 0,
+ 'time_estimated' => 0,
+ ),
+ );
+
+ foreach ($rows as $row) {
+ $key = $row['is_active'] == Task::STATUS_OPEN ? 'open' : 'closed';
+ $metrics[$key]['time_spent'] = $row['time_spent'];
+ $metrics[$key]['time_estimated'] = $row['time_estimated'];
+ }
+
+ return $metrics;
+ }
+}
diff --git a/app/Analytic/TaskDistributionAnalytic.php b/app/Analytic/TaskDistributionAnalytic.php
new file mode 100644
index 00000000..838652e3
--- /dev/null
+++ b/app/Analytic/TaskDistributionAnalytic.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Kanboard\Analytic;
+
+use Kanboard\Core\Base;
+
+/**
+ * Task Distribution
+ *
+ * @package analytic
+ * @author Frederic Guillot
+ */
+class TaskDistributionAnalytic extends Base
+{
+ /**
+ * Build report
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function build($project_id)
+ {
+ $metrics = array();
+ $total = 0;
+ $columns = $this->column->getAll($project_id);
+
+ foreach ($columns as $column) {
+ $nb_tasks = $this->taskFinder->countByColumnId($project_id, $column['id']);
+ $total += $nb_tasks;
+
+ $metrics[] = array(
+ 'column_title' => $column['title'],
+ 'nb_tasks' => $nb_tasks,
+ );
+ }
+
+ if ($total === 0) {
+ return array();
+ }
+
+ foreach ($metrics as &$metric) {
+ $metric['percentage'] = round(($metric['nb_tasks'] * 100) / $total, 2);
+ }
+
+ return $metrics;
+ }
+}
diff --git a/app/Analytic/UserDistributionAnalytic.php b/app/Analytic/UserDistributionAnalytic.php
new file mode 100644
index 00000000..e1815f9c
--- /dev/null
+++ b/app/Analytic/UserDistributionAnalytic.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Kanboard\Analytic;
+
+use Kanboard\Core\Base;
+
+/**
+ * User Distribution
+ *
+ * @package analytic
+ * @author Frederic Guillot
+ */
+class UserDistributionAnalytic extends Base
+{
+ /**
+ * Build Report
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function build($project_id)
+ {
+ $metrics = array();
+ $total = 0;
+ $tasks = $this->taskFinder->getAll($project_id);
+ $users = $this->projectUserRole->getAssignableUsersList($project_id);
+
+ foreach ($tasks as $task) {
+ $user = isset($users[$task['owner_id']]) ? $users[$task['owner_id']] : $users[0];
+ $total++;
+
+ if (! isset($metrics[$user])) {
+ $metrics[$user] = array(
+ 'nb_tasks' => 0,
+ 'percentage' => 0,
+ 'user' => $user,
+ );
+ }
+
+ $metrics[$user]['nb_tasks']++;
+ }
+
+ if ($total === 0) {
+ return array();
+ }
+
+ foreach ($metrics as &$metric) {
+ $metric['percentage'] = round(($metric['nb_tasks'] * 100) / $total, 2);
+ }
+
+ ksort($metrics);
+
+ return array_values($metrics);
+ }
+}
diff --git a/app/Api/Action.php b/app/Api/Action.php
index 0ae91f10..9e3b86f6 100644
--- a/app/Api/Action.php
+++ b/app/Api/Action.php
@@ -12,17 +12,17 @@ class Action extends \Kanboard\Core\Base
{
public function getAvailableActions()
{
- return $this->action->getAvailableActions();
+ return $this->actionManager->getAvailableActions();
}
public function getAvailableActionEvents()
{
- return $this->action->getAvailableEvents();
+ return $this->eventManager->getAll();
}
public function getCompatibleActionEvents($action_name)
{
- return $this->action->getCompatibleEvents($action_name);
+ return $this->actionManager->getCompatibleEvents($action_name);
}
public function removeAction($action_id)
@@ -32,22 +32,10 @@ class Action extends \Kanboard\Core\Base
public function getActions($project_id)
{
- $actions = $this->action->getAllByProject($project_id);
-
- foreach ($actions as $index => $action) {
- $params = array();
-
- foreach ($action['params'] as $param) {
- $params[$param['name']] = $param['value'];
- }
-
- $actions[$index]['params'] = $params;
- }
-
- return $actions;
+ return $this->action->getAllByProject($project_id);
}
- public function createAction($project_id, $event_name, $action_name, $params)
+ public function createAction($project_id, $event_name, $action_name, array $params)
{
$values = array(
'project_id' => $project_id,
@@ -56,23 +44,23 @@ class Action extends \Kanboard\Core\Base
'params' => $params,
);
- list($valid, ) = $this->action->validateCreation($values);
+ list($valid, ) = $this->actionValidator->validateCreation($values);
if (! $valid) {
return false;
}
// Check if the action exists
- $actions = $this->action->getAvailableActions();
+ $actions = $this->actionManager->getAvailableActions();
if (! isset($actions[$action_name])) {
return false;
}
// Check the event
- $action = $this->action->load($action_name, $project_id, $event_name);
+ $action = $this->actionManager->getAction($action_name);
- if (! in_array($event_name, $action->getCompatibleEvents())) {
+ if (! in_array($event_name, $action->getEvents())) {
return false;
}
diff --git a/app/Api/App.php b/app/Api/App.php
index d082bcfb..635f1ce2 100644
--- a/app/Api/App.php
+++ b/app/Api/App.php
@@ -34,4 +34,14 @@ class App extends \Kanboard\Core\Base
{
return $this->color->getList();
}
+
+ public function getApplicationRoles()
+ {
+ return $this->role->getApplicationRoles();
+ }
+
+ public function getProjectRoles()
+ {
+ return $this->role->getProjectRoles();
+ }
}
diff --git a/app/Api/Auth.php b/app/Api/Auth.php
index b3627e4b..c7c5298c 100644
--- a/app/Api/Auth.php
+++ b/app/Api/Auth.php
@@ -3,7 +3,6 @@
namespace Kanboard\Api;
use JsonRPC\AuthenticationFailure;
-use Symfony\Component\EventDispatcher\Event;
/**
* Base class
@@ -24,15 +23,58 @@ class Auth extends Base
*/
public function checkCredentials($username, $password, $class, $method)
{
- $this->container['dispatcher']->dispatch('api.bootstrap', new Event);
+ $this->dispatcher->dispatch('app.bootstrap');
- if ($username !== 'jsonrpc' && ! $this->authentication->hasCaptcha($username) && $this->authentication->authenticate($username, $password)) {
+ if ($this->isUserAuthenticated($username, $password)) {
$this->checkProcedurePermission(true, $method);
- $this->userSession->refresh($this->user->getByUsername($username));
- } elseif ($username === 'jsonrpc' && $password === $this->config->get('api_token')) {
+ $this->userSession->initialize($this->user->getByUsername($username));
+ } elseif ($this->isAppAuthenticated($username, $password)) {
$this->checkProcedurePermission(false, $method);
} else {
throw new AuthenticationFailure('Wrong credentials');
}
}
+
+ /**
+ * Check user credentials
+ *
+ * @access public
+ * @param string $username
+ * @param string $password
+ * @return boolean
+ */
+ private function isUserAuthenticated($username, $password)
+ {
+ return $username !== 'jsonrpc' &&
+ ! $this->userLocking->isLocked($username) &&
+ $this->authenticationManager->passwordAuthentication($username, $password);
+ }
+
+ /**
+ * Check administrative credentials
+ *
+ * @access public
+ * @param string $username
+ * @param string $password
+ * @return boolean
+ */
+ private function isAppAuthenticated($username, $password)
+ {
+ return $username === 'jsonrpc' && $password === $this->getApiToken();
+ }
+
+ /**
+ * Get API Token
+ *
+ * @access private
+ * @return string
+ */
+ private function getApiToken()
+ {
+ if (defined('API_AUTHENTICATION_TOKEN')) {
+ return API_AUTHENTICATION_TOKEN;
+ }
+
+ return $this->config->get('api_token');
+ }
}
diff --git a/app/Api/Board.php b/app/Api/Board.php
index d615b1dc..185ac51a 100644
--- a/app/Api/Board.php
+++ b/app/Api/Board.php
@@ -15,39 +15,4 @@ class Board extends Base
$this->checkProjectPermission($project_id);
return $this->board->getBoard($project_id);
}
-
- public function getColumns($project_id)
- {
- return $this->board->getColumns($project_id);
- }
-
- public function getColumn($column_id)
- {
- return $this->board->getColumn($column_id);
- }
-
- public function moveColumnUp($project_id, $column_id)
- {
- return $this->board->moveUp($project_id, $column_id);
- }
-
- public function moveColumnDown($project_id, $column_id)
- {
- return $this->board->moveDown($project_id, $column_id);
- }
-
- public function updateColumn($column_id, $title, $task_limit = 0, $description = '')
- {
- return $this->board->updateColumn($column_id, $title, $task_limit, $description);
- }
-
- public function addColumn($project_id, $title, $task_limit = 0, $description = '')
- {
- return $this->board->addColumn($project_id, $title, $task_limit, $description);
- }
-
- public function removeColumn($column_id)
- {
- return $this->board->removeColumn($column_id);
- }
}
diff --git a/app/Api/Category.php b/app/Api/Category.php
index 458eaef6..fbd61c56 100644
--- a/app/Api/Category.php
+++ b/app/Api/Category.php
@@ -32,7 +32,7 @@ class Category extends \Kanboard\Core\Base
'name' => $name,
);
- list($valid, ) = $this->category->validateCreation($values);
+ list($valid, ) = $this->categoryValidator->validateCreation($values);
return $valid ? $this->category->create($values) : false;
}
@@ -43,7 +43,7 @@ class Category extends \Kanboard\Core\Base
'name' => $name,
);
- list($valid, ) = $this->category->validateModification($values);
+ list($valid, ) = $this->categoryValidator->validateModification($values);
return $valid && $this->category->update($values);
}
}
diff --git a/app/Api/Column.php b/app/Api/Column.php
new file mode 100644
index 00000000..ddc3a5d0
--- /dev/null
+++ b/app/Api/Column.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Kanboard\Api;
+
+/**
+ * Column API controller
+ *
+ * @package api
+ * @author Frederic Guillot
+ */
+class Column extends Base
+{
+ public function getColumns($project_id)
+ {
+ return $this->column->getAll($project_id);
+ }
+
+ public function getColumn($column_id)
+ {
+ return $this->column->getById($column_id);
+ }
+
+ public function updateColumn($column_id, $title, $task_limit = 0, $description = '')
+ {
+ return $this->column->update($column_id, $title, $task_limit, $description);
+ }
+
+ public function addColumn($project_id, $title, $task_limit = 0, $description = '')
+ {
+ return $this->column->create($project_id, $title, $task_limit, $description);
+ }
+
+ public function removeColumn($column_id)
+ {
+ return $this->column->remove($column_id);
+ }
+
+ public function changeColumnPosition($project_id, $column_id, $position)
+ {
+ return $this->column->changePosition($project_id, $column_id, $position);
+ }
+}
diff --git a/app/Api/Comment.php b/app/Api/Comment.php
index 26b632e9..1fc1c708 100644
--- a/app/Api/Comment.php
+++ b/app/Api/Comment.php
@@ -25,15 +25,16 @@ class Comment extends \Kanboard\Core\Base
return $this->comment->remove($comment_id);
}
- public function createComment($task_id, $user_id, $content)
+ public function createComment($task_id, $user_id, $content, $reference = '')
{
$values = array(
'task_id' => $task_id,
'user_id' => $user_id,
'comment' => $content,
+ 'reference' => $reference,
);
- list($valid, ) = $this->comment->validateCreation($values);
+ list($valid, ) = $this->commentValidator->validateCreation($values);
return $valid ? $this->comment->create($values) : false;
}
@@ -45,7 +46,7 @@ class Comment extends \Kanboard\Core\Base
'comment' => $content,
);
- list($valid, ) = $this->comment->validateModification($values);
+ list($valid, ) = $this->commentValidator->validateModification($values);
return $valid && $this->comment->update($values);
}
}
diff --git a/app/Api/File.php b/app/Api/File.php
index be415ecb..71c31c76 100644
--- a/app/Api/File.php
+++ b/app/Api/File.php
@@ -10,45 +10,81 @@ use Kanboard\Core\ObjectStorage\ObjectStorageException;
* @package api
* @author Frederic Guillot
*/
-class File extends \Kanboard\Core\Base
+class File extends Base
{
- public function getFile($file_id)
+ public function getTaskFile($file_id)
{
- return $this->file->getById($file_id);
+ return $this->taskFile->getById($file_id);
}
- public function getAllFiles($task_id)
+ public function getAllTaskFiles($task_id)
{
- return $this->file->getAll($task_id);
+ return $this->taskFile->getAll($task_id);
}
- public function downloadFile($file_id)
+ public function downloadTaskFile($file_id)
{
try {
- $file = $this->file->getById($file_id);
+ $file = $this->taskFile->getById($file_id);
if (! empty($file)) {
return base64_encode($this->objectStorage->get($file['path']));
}
} catch (ObjectStorageException $e) {
$this->logger->error($e->getMessage());
+ return '';
+ }
+ }
+
+ public function createTaskFile($project_id, $task_id, $filename, $blob)
+ {
+ try {
+ return $this->taskFile->uploadContent($task_id, $filename, $blob);
+ } catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ return false;
}
+ }
+
+ public function removeTaskFile($file_id)
+ {
+ return $this->taskFile->remove($file_id);
+ }
- return '';
+ public function removeAllTaskFiles($task_id)
+ {
+ return $this->taskFile->removeAll($task_id);
+ }
+
+ // Deprecated procedures
+
+ public function getFile($file_id)
+ {
+ return $this->getTaskFile($file_id);
+ }
+
+ public function getAllFiles($task_id)
+ {
+ return $this->getAllTaskFiles($task_id);
+ }
+
+ public function downloadFile($file_id)
+ {
+ return $this->downloadTaskFile($file_id);
}
public function createFile($project_id, $task_id, $filename, $blob)
{
- return $this->file->uploadContent($project_id, $task_id, $filename, $blob);
+ return $this->createTaskFile($project_id, $task_id, $filename, $blob);
}
public function removeFile($file_id)
{
- return $this->file->remove($file_id);
+ return $this->removeTaskFile($file_id);
}
public function removeAllFiles($task_id)
{
- return $this->file->removeAll($task_id);
+ return $this->removeAllTaskFiles($task_id);
}
}
diff --git a/app/Api/Group.php b/app/Api/Group.php
new file mode 100644
index 00000000..a1e0a73d
--- /dev/null
+++ b/app/Api/Group.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Kanboard\Api;
+
+/**
+ * Group API controller
+ *
+ * @package api
+ * @author Frederic Guillot
+ */
+class Group extends \Kanboard\Core\Base
+{
+ public function createGroup($name, $external_id = '')
+ {
+ return $this->group->create($name, $external_id);
+ }
+
+ public function updateGroup($group_id, $name = null, $external_id = null)
+ {
+ $values = array(
+ 'id' => $group_id,
+ 'name' => $name,
+ 'external_id' => $external_id,
+ );
+
+ foreach ($values as $key => $value) {
+ if (is_null($value)) {
+ unset($values[$key]);
+ }
+ }
+
+ return $this->group->update($values);
+ }
+
+ public function removeGroup($group_id)
+ {
+ return $this->group->remove($group_id);
+ }
+
+ public function getGroup($group_id)
+ {
+ return $this->group->getById($group_id);
+ }
+
+ public function getAllGroups()
+ {
+ return $this->group->getAll();
+ }
+}
diff --git a/app/Api/GroupMember.php b/app/Api/GroupMember.php
new file mode 100644
index 00000000..de62f0c6
--- /dev/null
+++ b/app/Api/GroupMember.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Kanboard\Api;
+
+/**
+ * Group Member API controller
+ *
+ * @package api
+ * @author Frederic Guillot
+ */
+class GroupMember extends \Kanboard\Core\Base
+{
+ public function getGroupMembers($group_id)
+ {
+ return $this->groupMember->getMembers($group_id);
+ }
+
+ public function addGroupMember($group_id, $user_id)
+ {
+ return $this->groupMember->addUser($group_id, $user_id);
+ }
+
+ public function removeGroupMember($group_id, $user_id)
+ {
+ return $this->groupMember->removeUser($group_id, $user_id);
+ }
+
+ public function isGroupMember($group_id, $user_id)
+ {
+ return $this->groupMember->isMember($group_id, $user_id);
+ }
+}
diff --git a/app/Api/Link.php b/app/Api/Link.php
index d4df18fe..23a9916d 100644
--- a/app/Api/Link.php
+++ b/app/Api/Link.php
@@ -72,7 +72,7 @@ class Link extends \Kanboard\Core\Base
'opposite_label' => $opposite_label,
);
- list($valid, ) = $this->link->validateCreation($values);
+ list($valid, ) = $this->linkValidator->validateCreation($values);
return $valid ? $this->link->create($label, $opposite_label) : false;
}
@@ -93,7 +93,7 @@ class Link extends \Kanboard\Core\Base
'label' => $label,
);
- list($valid, ) = $this->link->validateModification($values);
+ list($valid, ) = $this->linkValidator->validateModification($values);
return $valid && $this->link->update($values);
}
diff --git a/app/Api/Me.php b/app/Api/Me.php
index 2c332a8c..ccc809ed 100644
--- a/app/Api/Me.php
+++ b/app/Api/Me.php
@@ -14,13 +14,13 @@ class Me extends Base
{
public function getMe()
{
- return $this->session['user'];
+ return $this->sessionStorage->user;
}
public function getMyDashboard()
{
$user_id = $this->userSession->getId();
- $projects = $this->project->getQueryColumnStats($this->projectPermission->getActiveMemberProjectIds($user_id))->findAll();
+ $projects = $this->project->getQueryColumnStats($this->projectPermission->getActiveProjectIds($user_id))->findAll();
$tasks = $this->taskFinder->getUserQuery($user_id)->findAll();
return array(
@@ -32,25 +32,29 @@ class Me extends Base
public function getMyActivityStream()
{
- $project_ids = $this->projectPermission->getActiveMemberProjectIds($this->userSession->getId());
+ $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId());
return $this->projectActivity->getProjects($project_ids, 100);
}
public function createMyPrivateProject($name, $description = null)
{
+ if ($this->config->get('disable_private_project', 0) == 1) {
+ return false;
+ }
+
$values = array(
'name' => $name,
'description' => $description,
'is_private' => 1,
);
- list($valid, ) = $this->project->validateCreation($values);
+ list($valid, ) = $this->projectValidator->validateCreation($values);
return $valid ? $this->project->create($values, $this->userSession->getId(), true) : false;
}
public function getMyProjectsList()
{
- return $this->projectPermission->getMemberProjects($this->userSession->getId());
+ return $this->projectUserRole->getProjectsByUser($this->userSession->getId());
}
public function getMyOverdueTasks()
@@ -60,7 +64,7 @@ class Me extends Base
public function getMyProjects()
{
- $project_ids = $this->projectPermission->getActiveMemberProjectIds($this->userSession->getId());
+ $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId());
$projects = $this->project->getAllByIds($project_ids);
return $this->formatProjects($projects);
diff --git a/app/Api/Project.php b/app/Api/Project.php
index f934432d..8e311f7f 100644
--- a/app/Api/Project.php
+++ b/app/Api/Project.php
@@ -69,7 +69,7 @@ class Project extends Base
'description' => $description
);
- list($valid, ) = $this->project->validateCreation($values);
+ list($valid, ) = $this->projectValidator->validateCreation($values);
return $valid ? $this->project->create($values) : false;
}
@@ -81,7 +81,7 @@ class Project extends Base
'description' => $description
);
- list($valid, ) = $this->project->validateModification($values);
+ list($valid, ) = $this->projectValidator->validateModification($values);
return $valid && $this->project->update($values);
}
}
diff --git a/app/Api/ProjectPermission.php b/app/Api/ProjectPermission.php
index 80323395..11e92af0 100644
--- a/app/Api/ProjectPermission.php
+++ b/app/Api/ProjectPermission.php
@@ -2,26 +2,71 @@
namespace Kanboard\Api;
+use Kanboard\Core\Security\Role;
+
/**
- * ProjectPermission API controller
+ * Project Permission API controller
*
* @package api
* @author Frederic Guillot
*/
class ProjectPermission extends \Kanboard\Core\Base
{
+ public function getProjectUsers($project_id)
+ {
+ return $this->projectUserRole->getAllUsers($project_id);
+ }
+
+ public function getAssignableUsers($project_id, $prepend_unassigned = false)
+ {
+ return $this->projectUserRole->getAssignableUsersList($project_id, $prepend_unassigned);
+ }
+
+ public function addProjectUser($project_id, $user_id, $role = Role::PROJECT_MEMBER)
+ {
+ return $this->projectUserRole->addUser($project_id, $user_id, $role);
+ }
+
+ public function addProjectGroup($project_id, $group_id, $role = Role::PROJECT_MEMBER)
+ {
+ return $this->projectGroupRole->addGroup($project_id, $group_id, $role);
+ }
+
+ public function removeProjectUser($project_id, $user_id)
+ {
+ return $this->projectUserRole->removeUser($project_id, $user_id);
+ }
+
+ public function removeProjectGroup($project_id, $group_id)
+ {
+ return $this->projectGroupRole->removeGroup($project_id, $group_id);
+ }
+
+ public function changeProjectUserRole($project_id, $user_id, $role)
+ {
+ return $this->projectUserRole->changeUserRole($project_id, $user_id, $role);
+ }
+
+ public function changeProjectGroupRole($project_id, $group_id, $role)
+ {
+ return $this->projectGroupRole->changeGroupRole($project_id, $group_id, $role);
+ }
+
+ // Deprecated
public function getMembers($project_id)
{
- return $this->projectPermission->getMembers($project_id);
+ return $this->getProjectUsers($project_id);
}
+ // Deprecated
public function revokeUser($project_id, $user_id)
{
- return $this->projectPermission->revokeMember($project_id, $user_id);
+ return $this->removeProjectUser($project_id, $user_id);
}
+ // Deprecated
public function allowUser($project_id, $user_id)
{
- return $this->projectPermission->addMember($project_id, $user_id);
+ return $this->addProjectUser($project_id, $user_id);
}
}
diff --git a/app/Api/Subtask.php b/app/Api/Subtask.php
index 7baee3d3..782fdb02 100644
--- a/app/Api/Subtask.php
+++ b/app/Api/Subtask.php
@@ -36,7 +36,7 @@ class Subtask extends \Kanboard\Core\Base
'status' => $status,
);
- list($valid, ) = $this->subtask->validateCreation($values);
+ list($valid, ) = $this->subtaskValidator->validateCreation($values);
return $valid ? $this->subtask->create($values) : false;
}
@@ -58,7 +58,7 @@ class Subtask extends \Kanboard\Core\Base
}
}
- list($valid, ) = $this->subtask->validateApiModification($values);
+ list($valid, ) = $this->subtaskValidator->validateApiModification($values);
return $valid && $this->subtask->update($values);
}
}
diff --git a/app/Api/Swimlane.php b/app/Api/Swimlane.php
index 84c699ab..03a2819f 100644
--- a/app/Api/Swimlane.php
+++ b/app/Api/Swimlane.php
@@ -48,9 +48,11 @@ class Swimlane extends \Kanboard\Core\Base
public function updateSwimlane($swimlane_id, $name, $description = null)
{
$values = array('id' => $swimlane_id, 'name' => $name);
+
if (!is_null($description)) {
$values['description'] = $description;
}
+
return $this->swimlane->update($values);
}
@@ -69,13 +71,8 @@ class Swimlane extends \Kanboard\Core\Base
return $this->swimlane->enable($project_id, $swimlane_id);
}
- public function moveSwimlaneUp($project_id, $swimlane_id)
- {
- return $this->swimlane->moveUp($project_id, $swimlane_id);
- }
-
- public function moveSwimlaneDown($project_id, $swimlane_id)
+ public function changeSwimlanePosition($project_id, $swimlane_id, $position)
{
- return $this->swimlane->moveDown($project_id, $swimlane_id);
+ return $this->swimlane->changePosition($project_id, $swimlane_id, $position);
}
}
diff --git a/app/Api/Task.php b/app/Api/Task.php
index 0dceb209..177a09c6 100644
--- a/app/Api/Task.php
+++ b/app/Api/Task.php
@@ -64,13 +64,31 @@ class Task extends Base
return $this->taskPosition->movePosition($project_id, $task_id, $column_id, $position, $swimlane_id);
}
+ public function moveTaskToProject($task_id, $project_id, $swimlane_id = null, $column_id = null, $category_id = null, $owner_id = null)
+ {
+ return $this->taskDuplication->moveToProject($task_id, $project_id, $swimlane_id, $column_id, $category_id, $owner_id);
+ }
+
+ public function duplicateTaskToProject($task_id, $project_id, $swimlane_id = null, $column_id = null, $category_id = null, $owner_id = null)
+ {
+ return $this->taskDuplication->duplicateToProject($task_id, $project_id, $swimlane_id, $column_id, $category_id, $owner_id);
+ }
+
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, $reference = '')
+ $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, $reference = '')
{
$this->checkProjectPermission($project_id);
+ if ($owner_id !== 0 && ! $this->projectPermission->isAssignable($project_id, $owner_id)) {
+ return false;
+ }
+
+ if ($this->userSession->isLogged()) {
+ $creator_id = $this->userSession->getId();
+ }
+
$values = array(
'title' => $title,
'project_id' => $project_id,
@@ -96,20 +114,28 @@ class Task extends Base
return $valid ? $this->taskCreation->create($values) : false;
}
- public function updateTask($id, $title = null, $project_id = null, $color_id = null, $owner_id = null,
- $creator_id = null, $date_due = null, $description = null, $category_id = null, $score = null,
- $recurrence_status = null, $recurrence_trigger = null, $recurrence_factor = null,
- $recurrence_timeframe = null, $recurrence_basedate = null, $reference = null)
+ public function updateTask($id, $title = null, $color_id = null, $owner_id = null,
+ $date_due = null, $description = null, $category_id = null, $score = null,
+ $recurrence_status = null, $recurrence_trigger = null, $recurrence_factor = null,
+ $recurrence_timeframe = null, $recurrence_basedate = null, $reference = null)
{
$this->checkTaskPermission($id);
+ $project_id = $this->taskFinder->getProjectId($id);
+
+ if ($project_id === 0) {
+ return false;
+ }
+
+ if ($owner_id !== null && $owner_id != 0 && ! $this->projectPermission->isAssignable($project_id, $owner_id)) {
+ return false;
+ }
+
$values = array(
'id' => $id,
'title' => $title,
- 'project_id' => $project_id,
'color_id' => $color_id,
'owner_id' => $owner_id,
- 'creator_id' => $creator_id,
'date_due' => $date_due,
'description' => $description,
'category_id' => $category_id,
diff --git a/app/Api/User.php b/app/Api/User.php
index 105723d3..48337ac6 100644
--- a/app/Api/User.php
+++ b/app/Api/User.php
@@ -2,7 +2,11 @@
namespace Kanboard\Api;
-use Kanboard\Auth\Ldap;
+use LogicException;
+use Kanboard\Core\Security\Role;
+use Kanboard\Core\Ldap\Client as LdapClient;
+use Kanboard\Core\Ldap\ClientException as LdapException;
+use Kanboard\Core\Ldap\User as LdapUser;
/**
* User API controller
@@ -17,6 +21,11 @@ class User extends \Kanboard\Core\Base
return $this->user->getById($user_id);
}
+ public function getUserByName($username)
+ {
+ return $this->user->getByUsername($username);
+ }
+
public function getAllUsers()
{
return $this->user->getAll();
@@ -27,7 +36,22 @@ class User extends \Kanboard\Core\Base
return $this->user->remove($user_id);
}
- public function createUser($username, $password, $name = '', $email = '', $is_admin = 0, $is_project_admin = 0)
+ public function disableUser($user_id)
+ {
+ return $this->user->disable($user_id);
+ }
+
+ public function enableUser($user_id)
+ {
+ return $this->user->enable($user_id);
+ }
+
+ public function isActiveUser($user_id)
+ {
+ return $this->user->isActive($user_id);
+ }
+
+ public function createUser($username, $password, $name = '', $email = '', $role = Role::APP_USER)
{
$values = array(
'username' => $username,
@@ -35,44 +59,53 @@ class User extends \Kanboard\Core\Base
'confirmation' => $password,
'name' => $name,
'email' => $email,
- 'is_admin' => $is_admin,
- 'is_project_admin' => $is_project_admin,
+ 'role' => $role,
);
- list($valid, ) = $this->user->validateCreation($values);
+ list($valid, ) = $this->userValidator->validateCreation($values);
return $valid ? $this->user->create($values) : false;
}
- public function createLdapUser($username = '', $email = '', $is_admin = 0, $is_project_admin = 0)
+ public function createLdapUser($username)
{
- $ldap = new Ldap($this->container);
- $user = $ldap->lookup($username, $email);
+ try {
- if (! $user) {
- return false;
- }
+ $ldap = LdapClient::connect();
+ $user = LdapUser::getUser($ldap, sprintf(LDAP_USER_FILTER, $username));
- $values = array(
- 'username' => $user['username'],
- 'name' => $user['name'],
- 'email' => $user['email'],
- 'is_ldap_user' => 1,
- 'is_admin' => $is_admin,
- 'is_project_admin' => $is_project_admin,
- );
+ if ($user === null) {
+ $this->logger->info('User not found in LDAP server');
+ return false;
+ }
- return $this->user->create($values);
+ if ($user->getUsername() === '') {
+ throw new LogicException('Username not found in LDAP profile, check the parameter LDAP_USER_ATTRIBUTE_USERNAME');
+ }
+
+ $values = array(
+ 'username' => $user->getUsername(),
+ 'name' => $user->getName(),
+ 'email' => $user->getEmail(),
+ 'role' => $user->getRole(),
+ 'is_ldap_user' => 1,
+ );
+
+ return $this->user->create($values);
+
+ } catch (LdapException $e) {
+ $this->logger->error($e->getMessage());
+ return false;
+ }
}
- public function updateUser($id, $username = null, $name = null, $email = null, $is_admin = null, $is_project_admin = null)
+ public function updateUser($id, $username = null, $name = null, $email = null, $role = null)
{
$values = array(
'id' => $id,
'username' => $username,
'name' => $name,
'email' => $email,
- 'is_admin' => $is_admin,
- 'is_project_admin' => $is_project_admin,
+ 'role' => $role,
);
foreach ($values as $key => $value) {
@@ -81,7 +114,7 @@ class User extends \Kanboard\Core\Base
}
}
- list($valid, ) = $this->user->validateApiModification($values);
+ list($valid, ) = $this->userValidator->validateApiModification($values);
return $valid && $this->user->update($values);
}
}
diff --git a/app/Auth/Database.php b/app/Auth/Database.php
deleted file mode 100644
index 91b17a5f..00000000
--- a/app/Auth/Database.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Model\User;
-use Kanboard\Event\AuthEvent;
-
-/**
- * Database authentication
- *
- * @package auth
- * @author Frederic Guillot
- */
-class Database extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'Database';
-
- /**
- * Authenticate a user
- *
- * @access public
- * @param string $username Username
- * @param string $password Password
- * @return boolean
- */
- public function authenticate($username, $password)
- {
- $user = $this->db
- ->table(User::TABLE)
- ->eq('username', $username)
- ->eq('disable_login_form', 0)
- ->eq('is_ldap_user', 0)
- ->findOne();
-
- if (is_array($user) && password_verify($password, $user['password'])) {
- $this->userSession->refresh($user);
- $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id']));
- return true;
- }
-
- return false;
- }
-}
diff --git a/app/Auth/DatabaseAuth.php b/app/Auth/DatabaseAuth.php
new file mode 100644
index 00000000..c13af687
--- /dev/null
+++ b/app/Auth/DatabaseAuth.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Kanboard\Auth;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Security\PasswordAuthenticationProviderInterface;
+use Kanboard\Core\Security\SessionCheckProviderInterface;
+use Kanboard\Model\User;
+use Kanboard\User\DatabaseUserProvider;
+
+/**
+ * Database Authentication Provider
+ *
+ * @package auth
+ * @author Frederic Guillot
+ */
+class DatabaseAuth extends Base implements PasswordAuthenticationProviderInterface, SessionCheckProviderInterface
+{
+ /**
+ * User properties
+ *
+ * @access protected
+ * @var array
+ */
+ protected $userInfo = array();
+
+ /**
+ * Username
+ *
+ * @access protected
+ * @var string
+ */
+ protected $username = '';
+
+ /**
+ * Password
+ *
+ * @access protected
+ * @var string
+ */
+ protected $password = '';
+
+ /**
+ * Get authentication provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Database';
+ }
+
+ /**
+ * Authenticate the user
+ *
+ * @access public
+ * @return boolean
+ */
+ public function authenticate()
+ {
+ $user = $this->db
+ ->table(User::TABLE)
+ ->columns('id', 'password')
+ ->eq('username', $this->username)
+ ->eq('disable_login_form', 0)
+ ->eq('is_ldap_user', 0)
+ ->eq('is_active', 1)
+ ->findOne();
+
+ if (! empty($user) && password_verify($this->password, $user['password'])) {
+ $this->userInfo = $user;
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the user session is valid
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isValidSession()
+ {
+ return $this->user->isActive($this->userSession->getId());
+ }
+
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return \Kanboard\User\DatabaseUserProvider
+ */
+ public function getUser()
+ {
+ if (empty($this->userInfo)) {
+ return null;
+ }
+
+ return new DatabaseUserProvider($this->userInfo);
+ }
+
+ /**
+ * Set username
+ *
+ * @access public
+ * @param string $username
+ */
+ public function setUsername($username)
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * Set password
+ *
+ * @access public
+ * @param string $password
+ */
+ public function setPassword($password)
+ {
+ $this->password = $password;
+ }
+}
diff --git a/app/Auth/Github.php b/app/Auth/Github.php
deleted file mode 100644
index b89dc5b8..00000000
--- a/app/Auth/Github.php
+++ /dev/null
@@ -1,123 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Event\AuthEvent;
-
-/**
- * Github backend
- *
- * @package auth
- */
-class Github extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'Github';
-
- /**
- * OAuth2 instance
- *
- * @access private
- * @var \Kanboard\Core\OAuth2
- */
- private $service;
-
- /**
- * Authenticate a Github user
- *
- * @access public
- * @param string $github_id Github user id
- * @return boolean
- */
- public function authenticate($github_id)
- {
- $user = $this->user->getByGithubId($github_id);
-
- if (! empty($user)) {
- $this->userSession->refresh($user);
- $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id']));
- return true;
- }
-
- return false;
- }
-
- /**
- * Unlink a Github account for a given user
- *
- * @access public
- * @param integer $user_id User id
- * @return boolean
- */
- public function unlink($user_id)
- {
- return $this->user->update(array(
- 'id' => $user_id,
- 'github_id' => '',
- ));
- }
-
- /**
- * Update the user table based on the Github profile information
- *
- * @access public
- * @param integer $user_id User id
- * @param array $profile Github profile
- * @return boolean
- */
- public function updateUser($user_id, array $profile)
- {
- $user = $this->user->getById($user_id);
-
- return $this->user->update(array(
- 'id' => $user_id,
- 'github_id' => $profile['id'],
- 'email' => empty($user['email']) ? $profile['email'] : $user['email'],
- 'name' => empty($user['name']) ? $profile['name'] : $user['name'],
- ));
- }
-
- /**
- * Get OAuth2 configured service
- *
- * @access public
- * @return Kanboard\Core\OAuth2
- */
- public function getService()
- {
- if (empty($this->service)) {
- $this->service = $this->oauth->createService(
- GITHUB_CLIENT_ID,
- GITHUB_CLIENT_SECRET,
- $this->helper->url->to('oauth', 'github', array(), '', true),
- GITHUB_OAUTH_AUTHORIZE_URL,
- GITHUB_OAUTH_TOKEN_URL,
- array()
- );
- }
-
- return $this->service;
- }
-
- /**
- * Get Github profile
- *
- * @access public
- * @param string $code
- * @return array
- */
- public function getProfile($code)
- {
- $this->getService()->getAccessToken($code);
-
- return $this->httpClient->getJson(
- GITHUB_API_URL.'user',
- array($this->getService()->getAuthorizationHeader())
- );
- }
-}
diff --git a/app/Auth/Gitlab.php b/app/Auth/Gitlab.php
deleted file mode 100644
index a59bc1fa..00000000
--- a/app/Auth/Gitlab.php
+++ /dev/null
@@ -1,123 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Event\AuthEvent;
-
-/**
- * Gitlab backend
- *
- * @package auth
- */
-class Gitlab extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'Gitlab';
-
- /**
- * OAuth2 instance
- *
- * @access private
- * @var \Kanboard\Core\OAuth2
- */
- private $service;
-
- /**
- * Authenticate a Gitlab user
- *
- * @access public
- * @param string $gitlab_id Gitlab user id
- * @return boolean
- */
- public function authenticate($gitlab_id)
- {
- $user = $this->user->getByGitlabId($gitlab_id);
-
- if (! empty($user)) {
- $this->userSession->refresh($user);
- $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id']));
- return true;
- }
-
- return false;
- }
-
- /**
- * Unlink a Gitlab account for a given user
- *
- * @access public
- * @param integer $user_id User id
- * @return boolean
- */
- public function unlink($user_id)
- {
- return $this->user->update(array(
- 'id' => $user_id,
- 'gitlab_id' => '',
- ));
- }
-
- /**
- * Update the user table based on the Gitlab profile information
- *
- * @access public
- * @param integer $user_id User id
- * @param array $profile Gitlab profile
- * @return boolean
- */
- public function updateUser($user_id, array $profile)
- {
- $user = $this->user->getById($user_id);
-
- return $this->user->update(array(
- 'id' => $user_id,
- 'gitlab_id' => $profile['id'],
- 'email' => empty($user['email']) ? $profile['email'] : $user['email'],
- 'name' => empty($user['name']) ? $profile['name'] : $user['name'],
- ));
- }
-
- /**
- * Get OAuth2 configured service
- *
- * @access public
- * @return Kanboard\Core\OAuth2
- */
- public function getService()
- {
- if (empty($this->service)) {
- $this->service = $this->oauth->createService(
- GITLAB_CLIENT_ID,
- GITLAB_CLIENT_SECRET,
- $this->helper->url->to('oauth', 'gitlab', array(), '', true),
- GITLAB_OAUTH_AUTHORIZE_URL,
- GITLAB_OAUTH_TOKEN_URL,
- array()
- );
- }
-
- return $this->service;
- }
-
- /**
- * Get Gitlab profile
- *
- * @access public
- * @param string $code
- * @return array
- */
- public function getProfile($code)
- {
- $this->getService()->getAccessToken($code);
-
- return $this->httpClient->getJson(
- GITLAB_API_URL.'user',
- array($this->getService()->getAuthorizationHeader())
- );
- }
-}
diff --git a/app/Auth/Google.php b/app/Auth/Google.php
deleted file mode 100644
index 32bcb4b1..00000000
--- a/app/Auth/Google.php
+++ /dev/null
@@ -1,124 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Event\AuthEvent;
-
-/**
- * Google backend
- *
- * @package auth
- * @author Frederic Guillot
- */
-class Google extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'Google';
-
- /**
- * OAuth2 instance
- *
- * @access private
- * @var \Kanboard\Core\OAuth2
- */
- private $service;
-
- /**
- * Authenticate a Google user
- *
- * @access public
- * @param string $google_id Google unique id
- * @return boolean
- */
- public function authenticate($google_id)
- {
- $user = $this->user->getByGoogleId($google_id);
-
- if (! empty($user)) {
- $this->userSession->refresh($user);
- $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id']));
- return true;
- }
-
- return false;
- }
-
- /**
- * Unlink a Google account for a given user
- *
- * @access public
- * @param integer $user_id User id
- * @return boolean
- */
- public function unlink($user_id)
- {
- return $this->user->update(array(
- 'id' => $user_id,
- 'google_id' => '',
- ));
- }
-
- /**
- * Update the user table based on the Google profile information
- *
- * @access public
- * @param integer $user_id User id
- * @param array $profile Google profile
- * @return boolean
- */
- public function updateUser($user_id, array $profile)
- {
- $user = $this->user->getById($user_id);
-
- return $this->user->update(array(
- 'id' => $user_id,
- 'google_id' => $profile['id'],
- 'email' => empty($user['email']) ? $profile['email'] : $user['email'],
- 'name' => empty($user['name']) ? $profile['name'] : $user['name'],
- ));
- }
-
- /**
- * Get OAuth2 configured service
- *
- * @access public
- * @return KanboardCore\OAuth2
- */
- public function getService()
- {
- if (empty($this->service)) {
- $this->service = $this->oauth->createService(
- GOOGLE_CLIENT_ID,
- GOOGLE_CLIENT_SECRET,
- $this->helper->url->to('oauth', 'google', array(), '', true),
- 'https://accounts.google.com/o/oauth2/auth',
- 'https://accounts.google.com/o/oauth2/token',
- array('https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile')
- );
- }
-
- return $this->service;
- }
-
- /**
- * Get Google profile
- *
- * @access public
- * @param string $code
- * @return array
- */
- public function getProfile($code)
- {
- $this->getService()->getAccessToken($code);
-
- return $this->httpClient->getJson(
- 'https://www.googleapis.com/oauth2/v1/userinfo',
- array($this->getService()->getAuthorizationHeader())
- );
- }
-}
diff --git a/app/Auth/Ldap.php b/app/Auth/Ldap.php
deleted file mode 100644
index c252be17..00000000
--- a/app/Auth/Ldap.php
+++ /dev/null
@@ -1,521 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Event\AuthEvent;
-
-/**
- * LDAP model
- *
- * @package auth
- * @author Frederic Guillot
- */
-class Ldap extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'LDAP';
-
- /**
- * Get LDAP server name
- *
- * @access public
- * @return string
- */
- public function getLdapServer()
- {
- return LDAP_SERVER;
- }
-
- /**
- * Get LDAP bind type
- *
- * @access public
- * @return integer
- */
- public function getLdapBindType()
- {
- return LDAP_BIND_TYPE;
- }
-
- /**
- * Get LDAP server port
- *
- * @access public
- * @return integer
- */
- public function getLdapPort()
- {
- return LDAP_PORT;
- }
-
- /**
- * Get LDAP username (proxy auth)
- *
- * @access public
- * @return string
- */
- public function getLdapUsername()
- {
- return LDAP_USERNAME;
- }
-
- /**
- * Get LDAP password (proxy auth)
- *
- * @access public
- * @return string
- */
- public function getLdapPassword()
- {
- return LDAP_PASSWORD;
- }
-
- /**
- * Get LDAP Base DN
- *
- * @access public
- * @return string
- */
- public function getLdapBaseDn()
- {
- return LDAP_ACCOUNT_BASE;
- }
-
- /**
- * Get LDAP account id attribute
- *
- * @access public
- * @return string
- */
- public function getLdapAccountId()
- {
- return LDAP_ACCOUNT_ID;
- }
-
- /**
- * Get LDAP account email attribute
- *
- * @access public
- * @return string
- */
- public function getLdapAccountEmail()
- {
- return LDAP_ACCOUNT_EMAIL;
- }
-
- /**
- * Get LDAP account name attribute
- *
- * @access public
- * @return string
- */
- public function getLdapAccountName()
- {
- return LDAP_ACCOUNT_FULLNAME;
- }
-
- /**
- * Get LDAP account memberof attribute
- *
- * @access public
- * @return string
- */
- public function getLdapAccountMemberOf()
- {
- return LDAP_ACCOUNT_MEMBEROF;
- }
-
- /**
- * Get LDAP admin group DN
- *
- * @access public
- * @return string
- */
- public function getLdapGroupAdmin()
- {
- return LDAP_GROUP_ADMIN_DN;
- }
-
- /**
- * Get LDAP project admin group DN
- *
- * @access public
- * @return string
- */
- public function getLdapGroupProjectAdmin()
- {
- return LDAP_GROUP_PROJECT_ADMIN_DN;
- }
-
- /**
- * Get LDAP username pattern
- *
- * @access public
- * @param string $username
- * @return string
- */
- public function getLdapUserPattern($username)
- {
- return sprintf(LDAP_USER_PATTERN, $username);
- }
-
- /**
- * Return true if the LDAP username is case sensitive
- *
- * @access public
- * @return boolean
- */
- public function isLdapAccountCaseSensitive()
- {
- return LDAP_USERNAME_CASE_SENSITIVE;
- }
-
- /**
- * Return true if the automatic account creation is enabled
- *
- * @access public
- * @return boolean
- */
- public function isLdapAccountCreationEnabled()
- {
- return LDAP_ACCOUNT_CREATION;
- }
-
- /**
- * Ge the list of attributes to fetch when reading the LDAP user entry
- *
- * Must returns array with index that start at 0 otherwise ldap_search returns a warning "Array initialization wrong"
- *
- * @access public
- * @return array
- */
- public function getProfileAttributes()
- {
- return array_values(array_filter(array(
- $this->getLdapAccountId(),
- $this->getLdapAccountName(),
- $this->getLdapAccountEmail(),
- $this->getLdapAccountMemberOf()
- )));
- }
-
- /**
- * Authenticate the user
- *
- * @access public
- * @param string $username Username
- * @param string $password Password
- * @return boolean
- */
- public function authenticate($username, $password)
- {
- $username = $this->isLdapAccountCaseSensitive() ? $username : strtolower($username);
- $result = $this->findUser($username, $password);
-
- if (is_array($result)) {
- $user = $this->user->getByUsername($username);
-
- if (! empty($user)) {
-
- // There is already a local user with that name
- if ($user['is_ldap_user'] == 0) {
- return false;
- }
- } else {
-
- // We create automatically a new user
- if ($this->isLdapAccountCreationEnabled() && $this->user->create($result) !== false) {
- $user = $this->user->getByUsername($username);
- } else {
- return false;
- }
- }
-
- // We open the session
- $this->userSession->refresh($user);
- $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id']));
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Find the user from the LDAP server
- *
- * @access public
- * @param string $username Username
- * @param string $password Password
- * @return boolean|array
- */
- public function findUser($username, $password)
- {
- $ldap = $this->connect();
-
- if ($ldap !== false && $this->bind($ldap, $username, $password)) {
- return $this->getProfile($ldap, $username, $password);
- }
-
- return false;
- }
-
- /**
- * LDAP connection
- *
- * @access public
- * @return resource|boolean
- */
- public function connect()
- {
- if (! function_exists('ldap_connect')) {
- $this->logger->error('LDAP: The PHP LDAP extension is required');
- return false;
- }
-
- // Skip SSL certificate verification
- if (! LDAP_SSL_VERIFY) {
- putenv('LDAPTLS_REQCERT=never');
- }
-
- $ldap = ldap_connect($this->getLdapServer(), $this->getLdapPort());
-
- if ($ldap === false) {
- $this->logger->error('LDAP: Unable to connect to the LDAP server');
- return false;
- }
-
- ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
- ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
- ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 1);
- ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 1);
-
- if (LDAP_START_TLS && ! @ldap_start_tls($ldap)) {
- $this->logger->error('LDAP: Unable to use ldap_start_tls()');
- return false;
- }
-
- return $ldap;
- }
-
- /**
- * LDAP authentication
- *
- * @access public
- * @param resource $ldap
- * @param string $username
- * @param string $password
- * @return boolean
- */
- public function bind($ldap, $username, $password)
- {
- if ($this->getLdapBindType() === 'user') {
- $ldap_username = sprintf($this->getLdapUsername(), $username);
- $ldap_password = $password;
- } elseif ($this->getLdapBindType() === 'proxy') {
- $ldap_username = $this->getLdapUsername();
- $ldap_password = $this->getLdapPassword();
- } else {
- $ldap_username = null;
- $ldap_password = null;
- }
-
- if (! @ldap_bind($ldap, $ldap_username, $ldap_password)) {
- $this->logger->error('LDAP: Unable to bind to server with: '.$ldap_username);
- $this->logger->error('LDAP: bind type='.$this->getLdapBindType());
- return false;
- }
-
- return true;
- }
-
- /**
- * Get LDAP user profile
- *
- * @access public
- * @param resource $ldap
- * @param string $username
- * @param string $password
- * @return boolean|array
- */
- public function getProfile($ldap, $username, $password)
- {
- $user_pattern = $this->getLdapUserPattern($username);
- $entries = $this->executeQuery($ldap, $user_pattern);
-
- if ($entries === false) {
- $this->logger->error('LDAP: Unable to get user profile: '.$user_pattern);
- return false;
- }
-
- if (@ldap_bind($ldap, $entries[0]['dn'], $password)) {
- return $this->prepareProfile($ldap, $entries, $username);
- }
-
- if (DEBUG) {
- $this->logger->debug('LDAP: wrong password for '.$entries[0]['dn']);
- }
-
- return false;
- }
-
- /**
- * Build user profile from LDAP information
- *
- * @access public
- * @param resource $ldap
- * @param array $entries
- * @param string $username
- * @return boolean|array
- */
- public function prepareProfile($ldap, array $entries, $username)
- {
- if ($this->getLdapAccountId() !== '') {
- $username = $this->getEntry($entries, $this->getLdapAccountId(), $username);
- }
-
- return array(
- 'username' => $username,
- 'name' => $this->getEntry($entries, $this->getLdapAccountName()),
- 'email' => $this->getEntry($entries, $this->getLdapAccountEmail()),
- 'is_admin' => (int) $this->isMemberOf($this->getEntries($entries, $this->getLdapAccountMemberOf()), $this->getLdapGroupAdmin()),
- 'is_project_admin' => (int) $this->isMemberOf($this->getEntries($entries, $this->getLdapAccountMemberOf()), $this->getLdapGroupProjectAdmin()),
- 'is_ldap_user' => 1,
- );
- }
-
- /**
- * Check group membership
- *
- * @access public
- * @param array $group_entries
- * @param string $group_dn
- * @return boolean
- */
- public function isMemberOf(array $group_entries, $group_dn)
- {
- if (! isset($group_entries['count']) || empty($group_dn)) {
- return false;
- }
-
- for ($i = 0; $i < $group_entries['count']; $i++) {
- if ($group_entries[$i] === $group_dn) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Retrieve info on LDAP user by username or email
- *
- * @access public
- * @param string $username
- * @param string $email
- * @return boolean|array
- */
- public function lookup($username = null, $email = null)
- {
- $query = $this->getLookupQuery($username, $email);
- if ($query === '') {
- return false;
- }
-
- // Connect and attempt anonymous or proxy binding
- $ldap = $this->connect();
- if ($ldap === false || ! $this->bind($ldap, null, null)) {
- return false;
- }
-
- // Try to find user
- $entries = $this->executeQuery($ldap, $query);
- if ($entries === false) {
- return false;
- }
-
- // User id not retrieved: LDAP_ACCOUNT_ID not properly configured
- if (empty($username) && ! isset($entries[0][$this->getLdapAccountId()][0])) {
- return false;
- }
-
- return $this->prepareProfile($ldap, $entries, $username);
- }
-
- /**
- * Execute LDAP query
- *
- * @access private
- * @param resource $ldap
- * @param string $query
- * @return boolean|array
- */
- private function executeQuery($ldap, $query)
- {
- $sr = @ldap_search($ldap, $this->getLdapBaseDn(), $query, $this->getProfileAttributes());
- if ($sr === false) {
- return false;
- }
-
- $entries = ldap_get_entries($ldap, $sr);
- if ($entries === false || count($entries) === 0 || $entries['count'] == 0) {
- return false;
- }
-
- return $entries;
- }
-
- /**
- * Get the LDAP query to find a user
- *
- * @access private
- * @param string $username
- * @param string $email
- * @return string
- */
- private function getLookupQuery($username, $email)
- {
- if (! empty($username) && ! empty($email)) {
- return '(&('.$this->getLdapUserPattern($username).')('.$this->getLdapAccountEmail().'='.$email.'))';
- } elseif (! empty($username)) {
- return $this->getLdapUserPattern($username);
- } elseif (! empty($email)) {
- return '('.$this->getLdapAccountEmail().'='.$email.')';
- }
-
- return '';
- }
-
- /**
- * Return one entry from a list of entries
- *
- * @access private
- * @param array $entries LDAP entries
- * @param string $key Key
- * @param string $default Default value if key not set in entry
- * @return string
- */
- private function getEntry(array $entries, $key, $default = '')
- {
- return isset($entries[0][$key][0]) ? $entries[0][$key][0] : $default;
- }
-
- /**
- * Return subset of entries
- *
- * @access private
- * @param array $entries
- * @param string $key
- * @param array $default
- * @return array
- */
- private function getEntries(array $entries, $key, $default = array())
- {
- return isset($entries[0][$key]) ? $entries[0][$key] : $default;
- }
-}
diff --git a/app/Auth/LdapAuth.php b/app/Auth/LdapAuth.php
new file mode 100644
index 00000000..b4efbb55
--- /dev/null
+++ b/app/Auth/LdapAuth.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Kanboard\Auth;
+
+use LogicException;
+use Kanboard\Core\Base;
+use Kanboard\Core\Ldap\Client as LdapClient;
+use Kanboard\Core\Ldap\ClientException as LdapException;
+use Kanboard\Core\Ldap\User as LdapUser;
+use Kanboard\Core\Security\PasswordAuthenticationProviderInterface;
+
+/**
+ * LDAP Authentication Provider
+ *
+ * @package auth
+ * @author Frederic Guillot
+ */
+class LdapAuth extends Base implements PasswordAuthenticationProviderInterface
+{
+ /**
+ * User properties
+ *
+ * @access protected
+ * @var \Kanboard\User\LdapUserProvider
+ */
+ protected $userInfo = null;
+
+ /**
+ * Username
+ *
+ * @access protected
+ * @var string
+ */
+ protected $username = '';
+
+ /**
+ * Password
+ *
+ * @access protected
+ * @var string
+ */
+ protected $password = '';
+
+ /**
+ * Get authentication provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return 'LDAP';
+ }
+
+ /**
+ * Authenticate the user
+ *
+ * @access public
+ * @return boolean
+ */
+ public function authenticate()
+ {
+ try {
+
+ $client = LdapClient::connect($this->getLdapUsername(), $this->getLdapPassword());
+ $user = LdapUser::getUser($client, $this->username);
+
+ if ($user === null) {
+ $this->logger->info('User not found in LDAP server');
+ return false;
+ }
+
+ if ($user->getUsername() === '') {
+ throw new LogicException('Username not found in LDAP profile, check the parameter LDAP_USER_ATTRIBUTE_USERNAME');
+ }
+
+ if ($client->authenticate($user->getDn(), $this->password)) {
+ $this->userInfo = $user;
+ return true;
+ }
+
+ } catch (LdapException $e) {
+ $this->logger->error($e->getMessage());
+ }
+
+ return false;
+ }
+
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return \Kanboard\User\LdapUserProvider
+ */
+ public function getUser()
+ {
+ return $this->userInfo;
+ }
+
+ /**
+ * Set username
+ *
+ * @access public
+ * @param string $username
+ */
+ public function setUsername($username)
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * Set password
+ *
+ * @access public
+ * @param string $password
+ */
+ public function setPassword($password)
+ {
+ $this->password = $password;
+ }
+
+ /**
+ * Get LDAP username (proxy auth)
+ *
+ * @access public
+ * @return string
+ */
+ public function getLdapUsername()
+ {
+ switch ($this->getLdapBindType()) {
+ case 'proxy':
+ return LDAP_USERNAME;
+ case 'user':
+ return sprintf(LDAP_USERNAME, $this->username);
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Get LDAP password (proxy auth)
+ *
+ * @access public
+ * @return string
+ */
+ public function getLdapPassword()
+ {
+ switch ($this->getLdapBindType()) {
+ case 'proxy':
+ return LDAP_PASSWORD;
+ case 'user':
+ return $this->password;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Get LDAP bind type
+ *
+ * @access public
+ * @return integer
+ */
+ public function getLdapBindType()
+ {
+ if (LDAP_BIND_TYPE !== 'user' && LDAP_BIND_TYPE !== 'proxy' && LDAP_BIND_TYPE !== 'anonymous') {
+ throw new LogicException('Wrong value for the parameter LDAP_BIND_TYPE');
+ }
+
+ return LDAP_BIND_TYPE;
+ }
+}
diff --git a/app/Auth/RememberMe.php b/app/Auth/RememberMe.php
deleted file mode 100644
index 0290e36c..00000000
--- a/app/Auth/RememberMe.php
+++ /dev/null
@@ -1,323 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Core\Request;
-use Kanboard\Event\AuthEvent;
-use Kanboard\Core\Security;
-
-/**
- * RememberMe model
- *
- * @package auth
- * @author Frederic Guillot
- */
-class RememberMe extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'RememberMe';
-
- /**
- * SQL table name
- *
- * @var string
- */
- const TABLE = 'remember_me';
-
- /**
- * Cookie name
- *
- * @var string
- */
- const COOKIE_NAME = '__R';
-
- /**
- * Expiration (60 days)
- *
- * @var integer
- */
- const EXPIRATION = 5184000;
-
- /**
- * Get a remember me record
- *
- * @access public
- * @param $token
- * @param $sequence
- * @return mixed
- */
- public function find($token, $sequence)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('token', $token)
- ->eq('sequence', $sequence)
- ->gt('expiration', time())
- ->findOne();
- }
-
- /**
- * Get all sessions for a given user
- *
- * @access public
- * @param integer $user_id User id
- * @return array
- */
- public function getAll($user_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('user_id', $user_id)
- ->desc('date_creation')
- ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration')
- ->findAll();
- }
-
- /**
- * Authenticate the user with the cookie
- *
- * @access public
- * @return bool
- */
- public function authenticate()
- {
- $credentials = $this->readCookie();
-
- if ($credentials !== false) {
- $record = $this->find($credentials['token'], $credentials['sequence']);
-
- if ($record) {
-
- // Update the sequence
- $this->writeCookie(
- $record['token'],
- $this->update($record['token']),
- $record['expiration']
- );
-
- // Create the session
- $this->userSession->refresh($this->user->getById($record['user_id']));
-
- // Do not ask 2FA for remember me session
- $this->session['2fa_validated'] = true;
-
- $this->container['dispatcher']->dispatch(
- 'auth.success',
- new AuthEvent(self::AUTH_NAME, $this->userSession->getId())
- );
-
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Remove a session record
- *
- * @access public
- * @param integer $session_id Session id
- * @return mixed
- */
- public function remove($session_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('id', $session_id)
- ->remove();
- }
-
- /**
- * Remove the current RememberMe session and the cookie
- *
- * @access public
- * @param integer $user_id User id
- */
- public function destroy($user_id)
- {
- $credentials = $this->readCookie();
-
- if ($credentials !== false) {
- $this->deleteCookie();
-
- $this->db
- ->table(self::TABLE)
- ->eq('user_id', $user_id)
- ->eq('token', $credentials['token'])
- ->remove();
- }
- }
-
- /**
- * Create a new RememberMe session
- *
- * @access public
- * @param integer $user_id User id
- * @param string $ip IP Address
- * @param string $user_agent User Agent
- * @return array
- */
- public function create($user_id, $ip, $user_agent)
- {
- $token = hash('sha256', $user_id.$user_agent.$ip.Security::generateToken());
- $sequence = Security::generateToken();
- $expiration = time() + self::EXPIRATION;
-
- $this->cleanup($user_id);
-
- $this
- ->db
- ->table(self::TABLE)
- ->insert(array(
- 'user_id' => $user_id,
- 'ip' => $ip,
- 'user_agent' => $user_agent,
- 'token' => $token,
- 'sequence' => $sequence,
- 'expiration' => $expiration,
- 'date_creation' => time(),
- ));
-
- return array(
- 'token' => $token,
- 'sequence' => $sequence,
- 'expiration' => $expiration,
- );
- }
-
- /**
- * Remove old sessions for a given user
- *
- * @access public
- * @param integer $user_id User id
- * @return bool
- */
- public function cleanup($user_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('user_id', $user_id)
- ->lt('expiration', time())
- ->remove();
- }
-
- /**
- * Return a new sequence token and update the database
- *
- * @access public
- * @param string $token Session token
- * @return string
- */
- public function update($token)
- {
- $new_sequence = Security::generateToken();
-
- $this->db
- ->table(self::TABLE)
- ->eq('token', $token)
- ->update(array('sequence' => $new_sequence));
-
- return $new_sequence;
- }
-
- /**
- * Encode the cookie
- *
- * @access public
- * @param string $token Session token
- * @param string $sequence Sequence token
- * @return string
- */
- public function encodeCookie($token, $sequence)
- {
- return implode('|', array($token, $sequence));
- }
-
- /**
- * Decode the value of a cookie
- *
- * @access public
- * @param string $value Raw cookie data
- * @return array
- */
- public function decodeCookie($value)
- {
- list($token, $sequence) = explode('|', $value);
-
- return array(
- 'token' => $token,
- 'sequence' => $sequence,
- );
- }
-
- /**
- * Return true if the current user has a RememberMe cookie
- *
- * @access public
- * @return bool
- */
- public function hasCookie()
- {
- return ! empty($_COOKIE[self::COOKIE_NAME]);
- }
-
- /**
- * Write and encode the cookie
- *
- * @access public
- * @param string $token Session token
- * @param string $sequence Sequence token
- * @param string $expiration Cookie expiration
- */
- public function writeCookie($token, $sequence, $expiration)
- {
- setcookie(
- self::COOKIE_NAME,
- $this->encodeCookie($token, $sequence),
- $expiration,
- $this->helper->url->dir(),
- null,
- Request::isHTTPS(),
- true
- );
- }
-
- /**
- * Read and decode the cookie
- *
- * @access public
- * @return mixed
- */
- public function readCookie()
- {
- if (empty($_COOKIE[self::COOKIE_NAME])) {
- return false;
- }
-
- return $this->decodeCookie($_COOKIE[self::COOKIE_NAME]);
- }
-
- /**
- * Remove the cookie
- *
- * @access public
- */
- public function deleteCookie()
- {
- setcookie(
- self::COOKIE_NAME,
- '',
- time() - 3600,
- $this->helper->url->dir(),
- null,
- Request::isHTTPS(),
- true
- );
- }
-}
diff --git a/app/Auth/RememberMeAuth.php b/app/Auth/RememberMeAuth.php
new file mode 100644
index 00000000..509a511d
--- /dev/null
+++ b/app/Auth/RememberMeAuth.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Kanboard\Auth;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Security\PreAuthenticationProviderInterface;
+use Kanboard\User\DatabaseUserProvider;
+
+/**
+ * Rember Me Cookie Authentication Provider
+ *
+ * @package auth
+ * @author Frederic Guillot
+ */
+class RememberMeAuth extends Base implements PreAuthenticationProviderInterface
+{
+ /**
+ * User properties
+ *
+ * @access protected
+ * @var array
+ */
+ protected $userInfo = array();
+
+ /**
+ * Get authentication provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return 'RememberMe';
+ }
+
+ /**
+ * Authenticate the user
+ *
+ * @access public
+ * @return boolean
+ */
+ public function authenticate()
+ {
+ $credentials = $this->rememberMeCookie->read();
+
+ if ($credentials !== false) {
+ $session = $this->rememberMeSession->find($credentials['token'], $credentials['sequence']);
+
+ if (! empty($session)) {
+ $this->rememberMeCookie->write(
+ $session['token'],
+ $this->rememberMeSession->updateSequence($session['token']),
+ $session['expiration']
+ );
+
+ $this->userInfo = $this->user->getById($session['user_id']);
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return DatabaseUserProvider
+ */
+ public function getUser()
+ {
+ if (empty($this->userInfo)) {
+ return null;
+ }
+
+ return new DatabaseUserProvider($this->userInfo);
+ }
+}
diff --git a/app/Auth/ReverseProxy.php b/app/Auth/ReverseProxy.php
deleted file mode 100644
index 1910ad35..00000000
--- a/app/Auth/ReverseProxy.php
+++ /dev/null
@@ -1,83 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-use Kanboard\Core\Base;
-use Kanboard\Event\AuthEvent;
-
-/**
- * ReverseProxy backend
- *
- * @package auth
- * @author Sylvain Veyrié
- */
-class ReverseProxy extends Base
-{
- /**
- * Backend name
- *
- * @var string
- */
- const AUTH_NAME = 'ReverseProxy';
-
- /**
- * Get username from the reverse proxy
- *
- * @access public
- * @return string
- */
- public function getUsername()
- {
- return isset($_SERVER[REVERSE_PROXY_USER_HEADER]) ? $_SERVER[REVERSE_PROXY_USER_HEADER] : '';
- }
-
- /**
- * Authenticate the user with the HTTP header
- *
- * @access public
- * @return bool
- */
- public function authenticate()
- {
- if (isset($_SERVER[REVERSE_PROXY_USER_HEADER])) {
- $login = $_SERVER[REVERSE_PROXY_USER_HEADER];
- $user = $this->user->getByUsername($login);
-
- if (empty($user)) {
- $this->createUser($login);
- $user = $this->user->getByUsername($login);
- }
-
- $this->userSession->refresh($user);
- $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id']));
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Create automatically a new local user after the authentication
- *
- * @access private
- * @param string $login Username
- * @return bool
- */
- private function createUser($login)
- {
- $email = strpos($login, '@') !== false ? $login : '';
-
- if (REVERSE_PROXY_DEFAULT_DOMAIN !== '' && empty($email)) {
- $email = $login.'@'.REVERSE_PROXY_DEFAULT_DOMAIN;
- }
-
- return $this->user->create(array(
- 'email' => $email,
- 'username' => $login,
- 'is_admin' => REVERSE_PROXY_DEFAULT_ADMIN === $login,
- 'is_ldap_user' => 1,
- 'disable_login_form' => 1,
- ));
- }
-}
diff --git a/app/Auth/ReverseProxyAuth.php b/app/Auth/ReverseProxyAuth.php
new file mode 100644
index 00000000..b9730c5c
--- /dev/null
+++ b/app/Auth/ReverseProxyAuth.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Kanboard\Auth;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Security\PreAuthenticationProviderInterface;
+use Kanboard\Core\Security\SessionCheckProviderInterface;
+use Kanboard\User\ReverseProxyUserProvider;
+
+/**
+ * Reverse-Proxy Authentication Provider
+ *
+ * @package auth
+ * @author Frederic Guillot
+ */
+class ReverseProxyAuth extends Base implements PreAuthenticationProviderInterface, SessionCheckProviderInterface
+{
+ /**
+ * User properties
+ *
+ * @access protected
+ * @var \Kanboard\User\ReverseProxyUserProvider
+ */
+ protected $userInfo = null;
+
+ /**
+ * Get authentication provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return 'ReverseProxy';
+ }
+
+ /**
+ * Authenticate the user
+ *
+ * @access public
+ * @return boolean
+ */
+ public function authenticate()
+ {
+ $username = $this->request->getRemoteUser();
+
+ if (! empty($username)) {
+ $this->userInfo = new ReverseProxyUserProvider($username);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the user session is valid
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isValidSession()
+ {
+ return $this->request->getRemoteUser() === $this->userSession->getUsername();
+ }
+
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return ReverseProxyUserProvider
+ */
+ public function getUser()
+ {
+ return $this->userInfo;
+ }
+}
diff --git a/app/Auth/TotpAuth.php b/app/Auth/TotpAuth.php
new file mode 100644
index 00000000..f4304930
--- /dev/null
+++ b/app/Auth/TotpAuth.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Kanboard\Auth;
+
+use Otp\Otp;
+use Otp\GoogleAuthenticator;
+use Base32\Base32;
+use Kanboard\Core\Base;
+use Kanboard\Core\Security\PostAuthenticationProviderInterface;
+
+/**
+ * TOTP Authentication Provider
+ *
+ * @package auth
+ * @author Frederic Guillot
+ */
+class TotpAuth extends Base implements PostAuthenticationProviderInterface
+{
+ /**
+ * User pin code
+ *
+ * @access protected
+ * @var string
+ */
+ protected $code = '';
+
+ /**
+ * Private key
+ *
+ * @access protected
+ * @var string
+ */
+ protected $secret = '';
+
+ /**
+ * Get authentication provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return t('Time-based One-time Password Algorithm');
+ }
+
+ /**
+ * Authenticate the user
+ *
+ * @access public
+ * @return boolean
+ */
+ public function authenticate()
+ {
+ $otp = new Otp;
+ return $otp->checkTotp(Base32::decode($this->secret), $this->code);
+ }
+
+ /**
+ * Called before to prompt the user
+ *
+ * @access public
+ */
+ public function beforeCode()
+ {
+
+ }
+
+ /**
+ * Set validation code
+ *
+ * @access public
+ * @param string $code
+ */
+ public function setCode($code)
+ {
+ $this->code = $code;
+ }
+
+ /**
+ * Generate secret
+ *
+ * @access public
+ * @return string
+ */
+ public function generateSecret()
+ {
+ $this->secret = GoogleAuthenticator::generateRandom();
+ return $this->secret;
+ }
+
+ /**
+ * Set secret token
+ *
+ * @access public
+ * @param string $secret
+ */
+ public function setSecret($secret)
+ {
+ $this->secret = $secret;
+ }
+
+ /**
+ * Get secret token
+ *
+ * @access public
+ * @return string
+ */
+ public function getSecret()
+ {
+ return $this->secret;
+ }
+
+ /**
+ * Get QR code url
+ *
+ * @access public
+ * @param string $label
+ * @return string
+ */
+ public function getQrCodeUrl($label)
+ {
+ if (empty($this->secret)) {
+ return '';
+ }
+
+ return GoogleAuthenticator::getQrCodeUrl('totp', $label, $this->secret);
+ }
+
+ /**
+ * Get key url (empty if no url can be provided)
+ *
+ * @access public
+ * @param string $label
+ * @return string
+ */
+ public function getKeyUrl($label)
+ {
+ if (empty($this->secret)) {
+ return '';
+ }
+
+ return GoogleAuthenticator::getKeyUri('totp', $label, $this->secret);
+ }
+}
diff --git a/app/Console/Base.php b/app/Console/Base.php
index 4c5caf73..ac89207d 100644
--- a/app/Console/Base.php
+++ b/app/Console/Base.php
@@ -11,18 +11,19 @@ use Symfony\Component\Console\Command\Command;
* @package console
* @author Frederic Guillot
*
- * @property \Kanboard\Model\Notification $notification
- * @property \Kanboard\Model\Project $project
- * @property \Kanboard\Model\ProjectPermission $projectPermission
- * @property \Kanboard\Model\ProjectAnalytic $projectAnalytic
- * @property \Kanboard\Model\ProjectDailyColumnStats $projectDailyColumnStats
- * @property \Kanboard\Model\ProjectDailyStats $projectDailyStats
- * @property \Kanboard\Model\SubtaskExport $subtaskExport
- * @property \Kanboard\Model\OverdueNotification $overdueNotification
- * @property \Kanboard\Model\Task $task
- * @property \Kanboard\Model\TaskExport $taskExport
- * @property \Kanboard\Model\TaskFinder $taskFinder
- * @property \Kanboard\Model\Transition $transition
+ * @property \Kanboard\Model\Notification $notification
+ * @property \Kanboard\Model\Project $project
+ * @property \Kanboard\Model\ProjectPermission $projectPermission
+ * @property \Kanboard\Model\ProjectAnalytic $projectAnalytic
+ * @property \Kanboard\Model\ProjectDailyColumnStats $projectDailyColumnStats
+ * @property \Kanboard\Model\ProjectDailyStats $projectDailyStats
+ * @property \Kanboard\Model\SubtaskExport $subtaskExport
+ * @property \Kanboard\Model\OverdueNotification $overdueNotification
+ * @property \Kanboard\Model\Task $task
+ * @property \Kanboard\Model\TaskExport $taskExport
+ * @property \Kanboard\Model\TaskFinder $taskFinder
+ * @property \Kanboard\Model\Transition $transition
+ * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher
*/
abstract class Base extends Command
{
diff --git a/app/Console/Cronjob.php b/app/Console/Cronjob.php
new file mode 100644
index 00000000..3a5c5596
--- /dev/null
+++ b/app/Console/Cronjob.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Kanboard\Console;
+
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Output\NullOutput;
+
+class Cronjob extends Base
+{
+ private $commands = array(
+ 'projects:daily-stats',
+ 'notification:overdue-tasks',
+ 'trigger:tasks',
+ );
+
+ protected function configure()
+ {
+ $this
+ ->setName('cronjob')
+ ->setDescription('Execute daily cronjob');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ foreach ($this->commands as $command) {
+ $job = $this->getApplication()->find($command);
+ $job->run(new ArrayInput(array('command' => $command)), new NullOutput());
+ }
+ }
+}
diff --git a/app/Console/TaskTrigger.php b/app/Console/TaskTrigger.php
new file mode 100644
index 00000000..8d707211
--- /dev/null
+++ b/app/Console/TaskTrigger.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Kanboard\Console;
+
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Kanboard\Model\Task;
+use Kanboard\Event\TaskListEvent;
+
+class TaskTrigger extends Base
+{
+ protected function configure()
+ {
+ $this
+ ->setName('trigger:tasks')
+ ->setDescription('Trigger scheduler event for all tasks');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ foreach ($this->getProjectIds() as $project_id) {
+ $tasks = $this->taskFinder->getAll($project_id);
+ $nb_tasks = count($tasks);
+
+ if ($nb_tasks > 0) {
+ $output->writeln('Trigger task event: project_id='.$project_id.', nb_tasks='.$nb_tasks);
+ $this->sendEvent($tasks, $project_id);
+ }
+ }
+ }
+
+ private function getProjectIds()
+ {
+ $listeners = $this->dispatcher->getListeners(Task::EVENT_DAILY_CRONJOB);
+ $project_ids = array();
+
+ foreach ($listeners as $listener) {
+ $project_ids[] = $listener[0]->getProjectId();
+ }
+
+ return array_unique($project_ids);
+ }
+
+ private function sendEvent(array &$tasks, $project_id)
+ {
+ $event = new TaskListEvent(array('project_id' => $project_id));
+ $event->setTasks($tasks);
+
+ $this->dispatcher->dispatch(Task::EVENT_DAILY_CRONJOB, $event);
+ }
+}
diff --git a/app/Controller/Action.php b/app/Controller/Action.php
index 37d1c248..6c324324 100644
--- a/app/Controller/Action.php
+++ b/app/Controller/Action.php
@@ -18,17 +18,18 @@ class Action extends Base
public function index()
{
$project = $this->getProject();
+ $actions = $this->action->getAllByProject($project['id']);
- $this->response->html($this->projectLayout('action/index', array(
+ $this->response->html($this->helper->layout->project('action/index', array(
'values' => array('project_id' => $project['id']),
'project' => $project,
- 'actions' => $this->action->getAllByProject($project['id']),
- 'available_actions' => $this->action->getAvailableActions(),
- 'available_events' => $this->action->getAvailableEvents(),
- 'available_params' => $this->action->getAllActionParameters(),
- 'columns_list' => $this->board->getColumnsList($project['id']),
- 'users_list' => $this->projectPermission->getMemberList($project['id']),
- 'projects_list' => $this->project->getList(false),
+ 'actions' => $actions,
+ 'available_actions' => $this->actionManager->getAvailableActions(),
+ 'available_events' => $this->eventManager->getAll(),
+ 'available_params' => $this->actionManager->getAvailableParameters($actions),
+ 'columns_list' => $this->column->getList($project['id']),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id']),
+ 'projects_list' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()),
'colors_list' => $this->color->getList(),
'categories_list' => $this->category->getList($project['id']),
'links_list' => $this->link->getList(0, false),
@@ -50,10 +51,10 @@ class Action extends Base
$this->response->redirect($this->helper->url->to('action', 'index', array('project_id' => $project['id'])));
}
- $this->response->html($this->projectLayout('action/event', array(
+ $this->response->html($this->helper->layout->project('action/event', array(
'values' => $values,
'project' => $project,
- 'events' => $this->action->getCompatibleEvents($values['action_name']),
+ 'events' => $this->actionManager->getCompatibleEvents($values['action_name']),
'title' => t('Automatic actions')
)));
}
@@ -72,21 +73,21 @@ class Action extends Base
$this->response->redirect($this->helper->url->to('action', 'index', array('project_id' => $project['id'])));
}
- $action = $this->action->load($values['action_name'], $values['project_id'], $values['event_name']);
+ $action = $this->actionManager->getAction($values['action_name']);
$action_params = $action->getActionRequiredParameters();
if (empty($action_params)) {
$this->doCreation($project, $values + array('params' => array()));
}
- $projects_list = $this->project->getList(false);
+ $projects_list = $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId());
unset($projects_list[$project['id']]);
- $this->response->html($this->projectLayout('action/params', array(
+ $this->response->html($this->helper->layout->project('action/params', array(
'values' => $values,
'action_params' => $action_params,
- 'columns_list' => $this->board->getColumnsList($project['id']),
- 'users_list' => $this->projectPermission->getMemberList($project['id']),
+ 'columns_list' => $this->column->getList($project['id']),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id']),
'projects_list' => $projects_list,
'colors_list' => $this->color->getList(),
'categories_list' => $this->category->getList($project['id']),
@@ -115,13 +116,13 @@ class Action extends Base
*/
private function doCreation(array $project, array $values)
{
- list($valid, ) = $this->action->validateCreation($values);
+ list($valid, ) = $this->actionValidator->validateCreation($values);
if ($valid) {
if ($this->action->create($values) !== false) {
- $this->session->flash(t('Your automatic action have been created successfully.'));
+ $this->flash->success(t('Your automatic action have been created successfully.'));
} else {
- $this->session->flashError(t('Unable to create your automatic action.'));
+ $this->flash->failure(t('Unable to create your automatic action.'));
}
}
@@ -137,10 +138,10 @@ class Action extends Base
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('action/remove', array(
+ $this->response->html($this->helper->layout->project('action/remove', array(
'action' => $this->action->getById($this->request->getIntegerParam('action_id')),
- 'available_events' => $this->action->getAvailableEvents(),
- 'available_actions' => $this->action->getAvailableActions(),
+ 'available_events' => $this->eventManager->getAll(),
+ 'available_actions' => $this->actionManager->getAvailableActions(),
'project' => $project,
'title' => t('Remove an action')
)));
@@ -158,9 +159,9 @@ class Action extends Base
$action = $this->action->getById($this->request->getIntegerParam('action_id'));
if (! empty($action) && $this->action->remove($action['id'])) {
- $this->session->flash(t('Action removed successfully.'));
+ $this->flash->success(t('Action removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this action.'));
+ $this->flash->failure(t('Unable to remove this action.'));
}
$this->response->redirect($this->helper->url->to('action', 'index', array('project_id' => $project['id'])));
diff --git a/app/Controller/Activity.php b/app/Controller/Activity.php
index 24327c23..db520ebe 100644
--- a/app/Controller/Activity.php
+++ b/app/Controller/Activity.php
@@ -19,8 +19,7 @@ class Activity extends Base
{
$project = $this->getProject();
- $this->response->html($this->template->layout('activity/project', array(
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
+ $this->response->html($this->helper->layout->app('activity/project', array(
'events' => $this->projectActivity->getProject($project['id']),
'project' => $project,
'title' => t('%s\'s activity', $project['name'])
@@ -36,7 +35,7 @@ class Activity extends Base
{
$task = $this->getTask();
- $this->response->html($this->taskLayout('activity/task', array(
+ $this->response->html($this->helper->layout->task('activity/task', array(
'title' => $task['title'],
'task' => $task,
'events' => $this->projectActivity->getTask($task['id']),
diff --git a/app/Controller/Analytic.php b/app/Controller/Analytic.php
index 1082b462..6ce062c4 100644
--- a/app/Controller/Analytic.php
+++ b/app/Controller/Analytic.php
@@ -2,6 +2,8 @@
namespace Kanboard\Controller;
+use Kanboard\Model\Task as TaskModel;
+
/**
* Project Analytic controller
*
@@ -11,22 +13,6 @@ namespace Kanboard\Controller;
class Analytic extends Base
{
/**
- * Common layout for analytic views
- *
- * @access private
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- private function layout($template, array $params)
- {
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['content_for_sublayout'] = $this->template->render($template, $params);
-
- return $this->template->layout('analytic/layout', $params);
- }
-
- /**
* Show average Lead and Cycle time
*
* @access public
@@ -34,33 +20,49 @@ class Analytic extends Base
public function leadAndCycleTime()
{
$project = $this->getProject();
- $values = $this->request->getValues();
-
- $this->projectDailyStats->updateTotals($project['id'], date('Y-m-d'));
-
- $from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week')));
- $to = $this->request->getStringParam('to', date('Y-m-d'));
+ list($from, $to) = $this->getDates();
- if (! empty($values)) {
- $from = $values['from'];
- $to = $values['to'];
- }
-
- $this->response->html($this->layout('analytic/lead_cycle_time', array(
+ $this->response->html($this->helper->layout->analytic('analytic/lead_cycle_time', array(
'values' => array(
'from' => $from,
'to' => $to,
),
'project' => $project,
- 'average' => $this->projectAnalytic->getAverageLeadAndCycleTime($project['id']),
+ 'average' => $this->averageLeadCycleTimeAnalytic->build($project['id']),
'metrics' => $this->projectDailyStats->getRawMetrics($project['id'], $from, $to),
'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
+ 'date_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateFormats()),
'title' => t('Lead and Cycle time for "%s"', $project['name']),
)));
}
/**
+ * Show comparison between actual and estimated hours chart
+ *
+ * @access public
+ */
+ public function compareHours()
+ {
+ $project = $this->getProject();
+ $params = $this->getProjectFilters('analytic', 'compareHours');
+ $query = $this->taskFilter->create()->filterByProject($params['project']['id'])->getQuery();
+
+ $paginator = $this->paginator
+ ->setUrl('analytic', 'compareHours', array('project_id' => $project['id']))
+ ->setMax(30)
+ ->setOrder(TaskModel::TABLE.'.id')
+ ->setQuery($query)
+ ->calculate();
+
+ $this->response->html($this->helper->layout->analytic('analytic/compare_hours', array(
+ 'project' => $project,
+ 'paginator' => $paginator,
+ 'metrics' => $this->estimatedTimeComparisonAnalytic->build($project['id']),
+ 'title' => t('Compare hours for "%s"', $project['name']),
+ )));
+ }
+
+ /**
* Show average time spent by column
*
* @access public
@@ -69,9 +71,9 @@ class Analytic extends Base
{
$project = $this->getProject();
- $this->response->html($this->layout('analytic/avg_time_columns', array(
+ $this->response->html($this->helper->layout->analytic('analytic/avg_time_columns', array(
'project' => $project,
- 'metrics' => $this->projectAnalytic->getAverageTimeSpentByColumn($project['id']),
+ 'metrics' => $this->averageTimeSpentColumnAnalytic->build($project['id']),
'title' => t('Average time spent into each column for "%s"', $project['name']),
)));
}
@@ -85,9 +87,9 @@ class Analytic extends Base
{
$project = $this->getProject();
- $this->response->html($this->layout('analytic/tasks', array(
+ $this->response->html($this->helper->layout->analytic('analytic/tasks', array(
'project' => $project,
- 'metrics' => $this->projectAnalytic->getTaskRepartition($project['id']),
+ 'metrics' => $this->taskDistributionAnalytic->build($project['id']),
'title' => t('Task repartition for "%s"', $project['name']),
)));
}
@@ -101,9 +103,9 @@ class Analytic extends Base
{
$project = $this->getProject();
- $this->response->html($this->layout('analytic/users', array(
+ $this->response->html($this->helper->layout->analytic('analytic/users', array(
'project' => $project,
- 'metrics' => $this->projectAnalytic->getUserRepartition($project['id']),
+ 'metrics' => $this->userDistributionAnalytic->build($project['id']),
'title' => t('User repartition for "%s"', $project['name']),
)));
}
@@ -132,25 +134,18 @@ class Analytic extends Base
* Common method for CFD and Burdown chart
*
* @access private
+ * @param string $template
+ * @param string $column
+ * @param string $title
*/
private function commonAggregateMetrics($template, $column, $title)
{
$project = $this->getProject();
- $values = $this->request->getValues();
-
- $this->projectDailyColumnStats->updateTotals($project['id'], date('Y-m-d'));
-
- $from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week')));
- $to = $this->request->getStringParam('to', date('Y-m-d'));
-
- if (! empty($values)) {
- $from = $values['from'];
- $to = $values['to'];
- }
+ list($from, $to) = $this->getDates();
$display_graph = $this->projectDailyColumnStats->countDays($project['id'], $from, $to) >= 2;
- $this->response->html($this->layout($template, array(
+ $this->response->html($this->helper->layout->analytic($template, array(
'values' => array(
'from' => $from,
'to' => $to,
@@ -159,8 +154,23 @@ class Analytic extends Base
'metrics' => $display_graph ? $this->projectDailyColumnStats->getAggregatedMetrics($project['id'], $from, $to, $column) : array(),
'project' => $project,
'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
+ 'date_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateFormats()),
'title' => t($title, $project['name']),
)));
}
+
+ private function getDates()
+ {
+ $values = $this->request->getValues();
+
+ $from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week')));
+ $to = $this->request->getStringParam('to', date('Y-m-d'));
+
+ if (! empty($values)) {
+ $from = $values['from'];
+ $to = $values['to'];
+ }
+
+ return array($from, $to);
+ }
}
diff --git a/app/Controller/App.php b/app/Controller/App.php
index 2fae004c..1ce74506 100644
--- a/app/Controller/App.php
+++ b/app/Controller/App.php
@@ -13,22 +13,6 @@ use Kanboard\Model\Subtask as SubtaskModel;
class App extends Base
{
/**
- * Common layout for dashboard views
- *
- * @access private
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- private function layout($template, array $params)
- {
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['content_for_sublayout'] = $this->template->render($template, $params);
-
- return $this->template->layout('app/layout', $params);
- }
-
- /**
* Get project pagination
*
* @access private
@@ -42,7 +26,7 @@ class App extends Base
->setUrl('app', $action, array('pagination' => 'projects', 'user_id' => $user_id))
->setMax($max)
->setOrder('name')
- ->setQuery($this->project->getQueryColumnStats($this->projectPermission->getActiveMemberProjectIds($user_id)))
+ ->setQuery($this->project->getQueryColumnStats($this->projectPermission->getActiveProjectIds($user_id)))
->calculateOnlyIf($this->request->getStringParam('pagination') === 'projects');
}
@@ -101,7 +85,7 @@ class App extends Base
{
$user = $this->getUser();
- $this->response->html($this->layout('app/overview', array(
+ $this->response->html($this->helper->layout->dashboard('app/overview', array(
'title' => t('Dashboard'),
'project_paginator' => $this->getProjectPaginator($user['id'], 'index', 10),
'task_paginator' => $this->getTaskPaginator($user['id'], 'index', 10),
@@ -119,7 +103,7 @@ class App extends Base
{
$user = $this->getUser();
- $this->response->html($this->layout('app/tasks', array(
+ $this->response->html($this->helper->layout->dashboard('app/tasks', array(
'title' => t('My tasks'),
'paginator' => $this->getTaskPaginator($user['id'], 'tasks', 50),
'user' => $user,
@@ -135,7 +119,7 @@ class App extends Base
{
$user = $this->getUser();
- $this->response->html($this->layout('app/subtasks', array(
+ $this->response->html($this->helper->layout->dashboard('app/subtasks', array(
'title' => t('My subtasks'),
'paginator' => $this->getSubtaskPaginator($user['id'], 'subtasks', 50),
'user' => $user,
@@ -151,7 +135,7 @@ class App extends Base
{
$user = $this->getUser();
- $this->response->html($this->layout('app/projects', array(
+ $this->response->html($this->helper->layout->dashboard('app/projects', array(
'title' => t('My projects'),
'paginator' => $this->getProjectPaginator($user['id'], 'projects', 25),
'user' => $user,
@@ -167,9 +151,9 @@ class App extends Base
{
$user = $this->getUser();
- $this->response->html($this->layout('app/activity', array(
+ $this->response->html($this->helper->layout->dashboard('app/activity', array(
'title' => t('My activity stream'),
- 'events' => $this->projectActivity->getProjects($this->projectPermission->getActiveMemberProjectIds($user['id']), 100),
+ 'events' => $this->projectActivity->getProjects($this->projectPermission->getActiveProjectIds($user['id']), 100),
'user' => $user,
)));
}
@@ -181,7 +165,7 @@ class App extends Base
*/
public function calendar()
{
- $this->response->html($this->layout('app/calendar', array(
+ $this->response->html($this->helper->layout->dashboard('app/calendar', array(
'title' => t('My calendar'),
'user' => $this->getUser(),
)));
@@ -196,55 +180,10 @@ class App extends Base
{
$user = $this->getUser();
- $this->response->html($this->layout('app/notifications', array(
+ $this->response->html($this->helper->layout->dashboard('app/notifications', array(
'title' => t('My notifications'),
'notifications' => $this->userUnreadNotification->getAll($user['id']),
'user' => $user,
)));
}
-
- /**
- * Render Markdown text and reply with the HTML Code
- *
- * @access public
- */
- public function preview()
- {
- $payload = $this->request->getJson();
-
- if (empty($payload['text'])) {
- $this->response->html('<p>'.t('Nothing to preview...').'</p>');
- }
-
- $this->response->html($this->helper->text->markdown($payload['text']));
- }
-
- /**
- * Task autocompletion (Ajax)
- *
- * @access public
- */
- public function autocomplete()
- {
- $search = $this->request->getStringParam('term');
- $projects = $this->projectPermission->getActiveMemberProjectIds($this->userSession->getId());
-
- if (empty($projects)) {
- $this->response->json(array());
- }
-
- $filter = $this->taskFilterAutoCompleteFormatter
- ->create()
- ->filterByProjects($projects)
- ->excludeTasks(array($this->request->getIntegerParam('exclude_task_id')));
-
- // Search by task id or by title
- if (ctype_digit($search)) {
- $filter->filterById($search);
- } else {
- $filter->filterByTitle($search);
- }
-
- $this->response->json($filter->format());
- }
}
diff --git a/app/Controller/Auth.php b/app/Controller/Auth.php
index 95ad8d9e..46b5a546 100644
--- a/app/Controller/Auth.php
+++ b/app/Controller/Auth.php
@@ -2,8 +2,6 @@
namespace Kanboard\Controller;
-use Gregwar\Captcha\CaptchaBuilder;
-
/**
* Authentication controller
*
@@ -23,8 +21,8 @@ class Auth extends Base
$this->response->redirect($this->helper->url->to('app', 'index'));
}
- $this->response->html($this->template->layout('auth/index', array(
- 'captcha' => isset($values['username']) && $this->authentication->hasCaptcha($values['username']),
+ $this->response->html($this->helper->layout->app('auth/index', array(
+ 'captcha' => ! empty($values['username']) && $this->userLocking->hasCaptcha($values['username']),
'errors' => $errors,
'values' => $values,
'no_layout' => true,
@@ -40,16 +38,11 @@ class Auth extends Base
public function check()
{
$values = $this->request->getValues();
- list($valid, $errors) = $this->authentication->validateForm($values);
+ $this->sessionStorage->hasRememberMe = ! empty($values['remember_me']);
+ list($valid, $errors) = $this->authValidator->validateForm($values);
if ($valid) {
- if (! empty($this->session['login_redirect']) && ! filter_var($this->session['login_redirect'], FILTER_VALIDATE_URL)) {
- $redirect = $this->session['login_redirect'];
- unset($this->session['login_redirect']);
- $this->response->redirect($redirect);
- }
-
- $this->response->redirect($this->helper->url->to('app', 'index'));
+ $this->redirectAfterLogin();
}
$this->login($values, $errors);
@@ -62,23 +55,27 @@ class Auth extends Base
*/
public function logout()
{
- $this->authentication->backend('rememberMe')->destroy($this->userSession->getId());
- $this->session->close();
- $this->response->redirect($this->helper->url->to('auth', 'login'));
+ if (! DISABLE_LOGOUT) {
+ $this->sessionManager->close();
+ $this->response->redirect($this->helper->url->to('auth', 'login'));
+ } else {
+ $this->response->redirect($this->helper->url->to('auth', 'index'));
+ }
}
/**
- * Display captcha image
+ * Redirect the user after the authentication
*
- * @access public
+ * @access private
*/
- public function captcha()
+ private function redirectAfterLogin()
{
- $this->response->contentType('image/jpeg');
+ if (isset($this->sessionStorage->redirectAfterLogin) && ! empty($this->sessionStorage->redirectAfterLogin) && ! filter_var($this->sessionStorage->redirectAfterLogin, FILTER_VALIDATE_URL)) {
+ $redirect = $this->sessionStorage->redirectAfterLogin;
+ unset($this->sessionStorage->redirectAfterLogin);
+ $this->response->redirect($redirect);
+ }
- $builder = new CaptchaBuilder;
- $builder->build();
- $this->session['captcha'] = $builder->getPhrase();
- $builder->output();
+ $this->response->redirect($this->helper->url->to('app', 'index'));
}
}
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
index a955b12c..884c439c 100644
--- a/app/Controller/Base.php
+++ b/app/Controller/Base.php
@@ -2,11 +2,7 @@
namespace Kanboard\Controller;
-use Pimple\Container;
-use Kanboard\Core\Security;
-use Kanboard\Core\Request;
-use Kanboard\Core\Response;
-use Symfony\Component\EventDispatcher\Event;
+use Kanboard\Core\Security\Role;
/**
* Base controller
@@ -17,54 +13,22 @@ use Symfony\Component\EventDispatcher\Event;
abstract class Base extends \Kanboard\Core\Base
{
/**
- * Request instance
- *
- * @accesss protected
- * @var \Kanboard\Core\Request
- */
- protected $request;
-
- /**
- * Response instance
- *
- * @accesss protected
- * @var \Kanboard\Core\Response
- */
- protected $response;
-
- /**
- * Constructor
- *
- * @access public
- * @param \Pimple\Container $container
- */
- public function __construct(Container $container)
- {
- $this->container = $container;
- $this->request = new Request;
- $this->response = new Response;
-
- if (DEBUG) {
- $this->container['logger']->debug('START_REQUEST='.$_SERVER['REQUEST_URI']);
- }
- }
-
- /**
- * Destructor
+ * Method executed before each action
*
* @access public
*/
- public function __destruct()
+ public function beforeAction()
{
- if (DEBUG) {
- foreach ($this->container['db']->getLogMessages() as $message) {
- $this->container['logger']->debug($message);
- }
+ $this->sessionManager->open();
+ $this->dispatcher->dispatch('app.bootstrap');
+ $this->sendHeaders();
+ $this->authenticationManager->checkCurrentSession();
- $this->container['logger']->debug('SQL_QUERIES={nb}', array('nb' => $this->container['db']->nbQueries));
- $this->container['logger']->debug('RENDERING={time}', array('time' => microtime(true) - @$_SERVER['REQUEST_TIME_FLOAT']));
- $this->container['logger']->debug('MEMORY='.$this->helper->text->bytes(memory_get_usage()));
- $this->container['logger']->debug('END_REQUEST='.$_SERVER['REQUEST_URI']);
+ if (! $this->applicationAuthorization->isAllowed($this->router->getController(), $this->router->getAction(), Role::APP_PUBLIC)) {
+ $this->handleAuthentication();
+ $this->handlePostAuthentication();
+ $this->checkApplicationAuthorization();
+ $this->checkProjectAuthorization();
}
}
@@ -73,7 +37,7 @@ abstract class Base extends \Kanboard\Core\Base
*
* @access private
*/
- private function sendHeaders($action)
+ private function sendHeaders()
{
// HTTP secure headers
$this->response->csp($this->container['cspRules']);
@@ -81,7 +45,7 @@ abstract class Base extends \Kanboard\Core\Base
$this->response->xss();
// Allow the public board iframe inclusion
- if (ENABLE_XFRAME && $action !== 'readonly') {
+ if (ENABLE_XFRAME && $this->router->getAction() !== 'readonly') {
$this->response->xframe();
}
@@ -91,53 +55,34 @@ abstract class Base extends \Kanboard\Core\Base
}
/**
- * Method executed before each action
- *
- * @access public
- */
- public function beforeAction($controller, $action)
- {
- // Start the session
- $this->session->open($this->helper->url->dir());
- $this->sendHeaders($action);
- $this->container['dispatcher']->dispatch('session.bootstrap', new Event);
-
- if (! $this->acl->isPublicAction($controller, $action)) {
- $this->handleAuthentication();
- $this->handle2FA($controller, $action);
- $this->handleAuthorization($controller, $action);
-
- $this->session['has_subtask_inprogress'] = $this->subtask->hasSubtaskInProgress($this->userSession->getId());
- }
- }
-
- /**
* Check authentication
*
- * @access public
+ * @access private
*/
- public function handleAuthentication()
+ private function handleAuthentication()
{
- if (! $this->authentication->isAuthenticated()) {
+ if (! $this->userSession->isLogged() && ! $this->authenticationManager->preAuthentication()) {
if ($this->request->isAjax()) {
$this->response->text('Not Authorized', 401);
}
- $this->session['login_redirect'] = $this->request->getUri();
+ $this->sessionStorage->redirectAfterLogin = $this->request->getUri();
$this->response->redirect($this->helper->url->to('auth', 'login'));
}
}
/**
- * Check 2FA
+ * Handle Post-Authentication (2FA)
*
- * @access public
+ * @access private
*/
- public function handle2FA($controller, $action)
+ private function handlePostAuthentication()
{
+ $controller = strtolower($this->router->getController());
+ $action = strtolower($this->router->getAction());
$ignore = ($controller === 'twofactor' && in_array($action, array('code', 'check'))) || ($controller === 'auth' && $action === 'logout');
- if ($ignore === false && $this->userSession->has2FA() && ! $this->userSession->check2FA()) {
+ if ($ignore === false && $this->userSession->hasPostAuthentication() && ! $this->userSession->isPostAuthenticationValidated()) {
if ($this->request->isAjax()) {
$this->response->text('Not Authorized', 401);
}
@@ -147,11 +92,23 @@ abstract class Base extends \Kanboard\Core\Base
}
/**
- * Check page access and authorization
+ * Check application authorization
*
- * @access public
+ * @access private
*/
- public function handleAuthorization($controller, $action)
+ private function checkApplicationAuthorization()
+ {
+ if (! $this->helper->user->hasAccess($this->router->getController(), $this->router->getAction())) {
+ $this->forbidden();
+ }
+ }
+
+ /**
+ * Check project authorization
+ *
+ * @access private
+ */
+ private function checkProjectAuthorization()
{
$project_id = $this->request->getIntegerParam('project_id');
$task_id = $this->request->getIntegerParam('task_id');
@@ -161,7 +118,7 @@ abstract class Base extends \Kanboard\Core\Base
$project_id = $this->taskFinder->getProjectId($task_id);
}
- if (! $this->acl->isAllowed($controller, $action, $project_id)) {
+ if ($project_id > 0 && ! $this->helper->user->hasProjectAccess($this->router->getController(), $this->router->getAction(), $project_id)) {
$this->forbidden();
}
}
@@ -169,12 +126,12 @@ abstract class Base extends \Kanboard\Core\Base
/**
* Application not found page (404 error)
*
- * @access public
+ * @access protected
* @param boolean $no_layout Display the layout or not
*/
- public function notfound($no_layout = false)
+ protected function notfound($no_layout = false)
{
- $this->response->html($this->template->layout('app/notfound', array(
+ $this->response->html($this->helper->layout->app('app/notfound', array(
'title' => t('Page not found'),
'no_layout' => $no_layout,
)));
@@ -183,12 +140,16 @@ abstract class Base extends \Kanboard\Core\Base
/**
* Application forbidden page
*
- * @access public
+ * @access protected
* @param boolean $no_layout Display the layout or not
*/
- public function forbidden($no_layout = false)
+ protected function forbidden($no_layout = false)
{
- $this->response->html($this->template->layout('app/forbidden', array(
+ if ($this->request->isAjax()) {
+ $this->response->text('Access Forbidden', 403);
+ }
+
+ $this->response->html($this->helper->layout->app('app/forbidden', array(
'title' => t('Access Forbidden'),
'no_layout' => $no_layout,
)));
@@ -201,7 +162,7 @@ abstract class Base extends \Kanboard\Core\Base
*/
protected function checkCSRFParam()
{
- if (! Security::validateCSRFToken($this->request->getStringParam('csrf_token'))) {
+ if (! $this->token->validateCSRFToken($this->request->getStringParam('csrf_token'))) {
$this->forbidden();
}
}
@@ -219,43 +180,6 @@ abstract class Base extends \Kanboard\Core\Base
}
/**
- * Common layout for task views
- *
- * @access protected
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- protected function taskLayout($template, array $params)
- {
- $content = $this->template->render($template, $params);
- $params['task_content_for_layout'] = $content;
- $params['title'] = $params['task']['project_name'].' &gt; '.$params['task']['title'];
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
-
- return $this->template->layout('task/layout', $params);
- }
-
- /**
- * Common layout for project views
- *
- * @access protected
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- protected function projectLayout($template, array $params, $sidebar_template = 'project/sidebar')
- {
- $content = $this->template->render($template, $params);
- $params['project_content_for_layout'] = $content;
- $params['title'] = $params['project']['name'] === $params['title'] ? $params['title'] : $params['project']['name'].' &gt; '.$params['title'];
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['sidebar_template'] = $sidebar_template;
-
- return $this->template->layout('project/layout', $params);
- }
-
- /**
* Common method to get a task for task views
*
* @access protected
@@ -278,6 +202,36 @@ abstract class Base extends \Kanboard\Core\Base
}
/**
+ * Get Task or Project file
+ *
+ * @access protected
+ */
+ protected function getFile()
+ {
+ $task_id = $this->request->getIntegerParam('task_id');
+ $file_id = $this->request->getIntegerParam('file_id');
+ $model = 'projectFile';
+
+ if ($task_id > 0) {
+ $model = 'taskFile';
+ $project_id = $this->taskFinder->getProjectId($task_id);
+
+ if ($project_id !== $this->request->getIntegerParam('project_id')) {
+ $this->forbidden();
+ }
+ }
+
+ $file = $this->$model->getById($file_id);
+
+ if (empty($file)) {
+ $this->notfound();
+ }
+
+ $file['model'] = $model;
+ return $file;
+ }
+
+ /**
* Common method to get a project
*
* @access protected
@@ -287,11 +241,10 @@ abstract class Base extends \Kanboard\Core\Base
protected function getProject($project_id = 0)
{
$project_id = $this->request->getIntegerParam('project_id', $project_id);
- $project = $this->project->getById($project_id);
+ $project = $this->project->getByIdWithOwner($project_id);
if (empty($project)) {
- $this->session->flashError(t('Project not found.'));
- $this->response->redirect($this->helper->url->to('project', 'index'));
+ $this->notfound();
}
return $project;
@@ -319,15 +272,35 @@ abstract class Base extends \Kanboard\Core\Base
}
/**
+ * Get the current subtask
+ *
+ * @access protected
+ * @return array
+ */
+ protected function getSubtask()
+ {
+ $subtask = $this->subtask->getById($this->request->getIntegerParam('subtask_id'));
+
+ if (empty($subtask)) {
+ $this->notfound();
+ }
+
+ return $subtask;
+ }
+
+ /**
* Common method to get project filters
*
* @access protected
+ * @param string $controller
+ * @param string $action
+ * @return array
*/
protected function getProjectFilters($controller, $action)
{
$project = $this->getProject();
$search = $this->request->getStringParam('search', $this->userSession->getFilters($project['id']));
- $board_selector = $this->projectPermission->getAllowedProjects($this->userSession->getId());
+ $board_selector = $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId());
unset($board_selector[$project['id']]);
$filters = array(
@@ -344,6 +317,30 @@ abstract class Base extends \Kanboard\Core\Base
'board_selector' => $board_selector,
'filters' => $filters,
'title' => $project['name'],
+ 'description' => $this->getProjectDescription($project),
);
}
+
+ /**
+ * Get project description
+ *
+ * @access protected
+ * @param array &$project
+ * @return string
+ */
+ protected function getProjectDescription(array &$project)
+ {
+ if ($project['owner_id'] > 0) {
+ $description = t('Project owner: ').'**'.$this->template->e($project['owner_name'] ?: $project['owner_username']).'**'.PHP_EOL.PHP_EOL;
+
+ if (! empty($project['description'])) {
+ $description .= '***'.PHP_EOL.PHP_EOL;
+ $description .= $project['description'];
+ }
+ } else {
+ $description = $project['description'];
+ }
+
+ return $description;
+ }
}
diff --git a/app/Controller/Board.php b/app/Controller/Board.php
index 2d75db89..199f1703 100644
--- a/app/Controller/Board.php
+++ b/app/Controller/Board.php
@@ -27,7 +27,7 @@ class Board extends Base
}
// Display the board with a specific layout
- $this->response->html($this->template->layout('board/view_public', array(
+ $this->response->html($this->helper->layout->app('board/view_public', array(
'project' => $project,
'swimlanes' => $this->board->getBoard($project['id']),
'title' => $project['name'],
@@ -49,12 +49,11 @@ class Board extends Base
{
$params = $this->getProjectFilters('board', 'show');
- $this->response->html($this->template->layout('board/view_private', array(
+ $this->response->html($this->helper->layout->app('board/view_private', array(
'categories_list' => $this->category->getList($params['project']['id'], false),
- 'users_list' => $this->projectPermission->getMemberList($params['project']['id'], false),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($params['project']['id'], false),
'custom_filters_list' => $this->customFilter->getAll($params['project']['id'], $this->userSession->getId()),
'swimlanes' => $this->taskFilter->search($params['filters']['search'])->getBoard($params['project']['id']),
- 'description' => $params['project']['description'],
'board_private_refresh_interval' => $this->config->get('board_private_refresh_interval'),
'board_highlight_period' => $this->config->get('board_highlight_period'),
) + $params));
@@ -73,10 +72,6 @@ class Board extends Base
return $this->response->status(403);
}
- if (! $this->projectPermission->isUserAllowed($project_id, $this->userSession->getId())) {
- $this->response->text('Forbidden', 403);
- }
-
$values = $this->request->getJson();
$result =$this->taskPosition->movePosition(
@@ -101,22 +96,18 @@ class Board extends Base
*/
public function check()
{
- if (! $this->request->isAjax()) {
- return $this->response->status(403);
- }
-
$project_id = $this->request->getIntegerParam('project_id');
$timestamp = $this->request->getIntegerParam('timestamp');
- if (! $this->projectPermission->isUserAllowed($project_id, $this->userSession->getId())) {
- $this->response->text('Forbidden', 403);
+ if (! $project_id || ! $this->request->isAjax()) {
+ return $this->response->status(403);
}
if (! $this->project->isModifiedSince($project_id, $timestamp)) {
return $this->response->status(304);
}
- $this->response->html($this->renderBoard($project_id));
+ return $this->response->html($this->renderBoard($project_id));
}
/**
@@ -126,14 +117,10 @@ class Board extends Base
*/
public function reload()
{
- if (! $this->request->isAjax()) {
- return $this->response->status(403);
- }
-
$project_id = $this->request->getIntegerParam('project_id');
- if (! $this->projectPermission->isUserAllowed($project_id, $this->userSession->getId())) {
- $this->response->text('Forbidden', 403);
+ if (! $project_id || ! $this->request->isAjax()) {
+ return $this->response->status(403);
}
$values = $this->request->getJson();
@@ -143,195 +130,6 @@ class Board extends Base
}
/**
- * Get links on mouseover
- *
- * @access public
- */
- public function tasklinks()
- {
- $task = $this->getTask();
- $this->response->html($this->template->render('board/tooltip_tasklinks', array(
- 'links' => $this->taskLink->getAll($task['id']),
- 'task' => $task,
- )));
- }
-
- /**
- * Get subtasks on mouseover
- *
- * @access public
- */
- public function subtasks()
- {
- $task = $this->getTask();
- $this->response->html($this->template->render('board/tooltip_subtasks', array(
- 'subtasks' => $this->subtask->getAll($task['id']),
- 'task' => $task,
- )));
- }
-
- /**
- * Display all attachments during the task mouseover
- *
- * @access public
- */
- public function attachments()
- {
- $task = $this->getTask();
-
- $this->response->html($this->template->render('board/tooltip_files', array(
- 'files' => $this->file->getAll($task['id']),
- 'task' => $task,
- )));
- }
-
- /**
- * Display comments during a task mouseover
- *
- * @access public
- */
- public function comments()
- {
- $task = $this->getTask();
-
- $this->response->html($this->template->render('board/tooltip_comments', array(
- 'comments' => $this->comment->getAll($task['id'], $this->userSession->getCommentSorting())
- )));
- }
-
- /**
- * Display task description
- *
- * @access public
- */
- public function description()
- {
- $task = $this->getTask();
-
- $this->response->html($this->template->render('board/tooltip_description', array(
- 'task' => $task
- )));
- }
-
- /**
- * Change a task assignee directly from the board
- *
- * @access public
- */
- public function changeAssignee()
- {
- $task = $this->getTask();
- $project = $this->project->getById($task['project_id']);
-
- $this->response->html($this->template->render('board/popover_assignee', array(
- 'values' => $task,
- 'users_list' => $this->projectPermission->getMemberList($project['id']),
- 'project' => $project,
- )));
- }
-
- /**
- * Validate an assignee modification
- *
- * @access public
- */
- public function updateAssignee()
- {
- $values = $this->request->getValues();
-
- list($valid, ) = $this->taskValidator->validateAssigneeModification($values);
-
- if ($valid && $this->taskModification->update($values)) {
- $this->session->flash(t('Task updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update your task.'));
- }
-
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id'])));
- }
-
- /**
- * Change a task category directly from the board
- *
- * @access public
- */
- public function changeCategory()
- {
- $task = $this->getTask();
- $project = $this->project->getById($task['project_id']);
-
- $this->response->html($this->template->render('board/popover_category', array(
- 'values' => $task,
- 'categories_list' => $this->category->getList($project['id']),
- 'project' => $project,
- )));
- }
-
- /**
- * Validate a category modification
- *
- * @access public
- */
- public function updateCategory()
- {
- $values = $this->request->getValues();
-
- list($valid, ) = $this->taskValidator->validateCategoryModification($values);
-
- if ($valid && $this->taskModification->update($values)) {
- $this->session->flash(t('Task updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update your task.'));
- }
-
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id'])));
- }
-
- /**
- * Screenshot popover
- *
- * @access public
- */
- public function screenshot()
- {
- $task = $this->getTask();
-
- $this->response->html($this->template->render('file/screenshot', array(
- 'task' => $task,
- 'redirect' => 'board',
- )));
- }
-
- /**
- * Get recurrence information on mouseover
- *
- * @access public
- */
- public function recurrence()
- {
- $task = $this->getTask();
-
- $this->response->html($this->template->render('task/recurring_info', array(
- 'task' => $task,
- 'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(),
- 'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(),
- 'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(),
- )));
- }
-
- /**
- * Display swimlane description in tooltip
- *
- * @access public
- */
- public function swimlane()
- {
- $this->getProject();
- $swimlane = $this->swimlane->getById($this->request->getIntegerParam('swimlane_id'));
- $this->response->html($this->template->render('board/tooltip_description', array('task' => $swimlane)));
- }
-
- /**
* Enable collapsed mode
*
* @access public
@@ -355,6 +153,7 @@ class Board extends Base
* Change display mode
*
* @access private
+ * @param boolean $mode
*/
private function changeDisplayMode($mode)
{
@@ -372,6 +171,7 @@ class Board extends Base
* Render board
*
* @access private
+ * @param integer $project_id
*/
private function renderBoard($project_id)
{
diff --git a/app/Controller/BoardPopover.php b/app/Controller/BoardPopover.php
new file mode 100644
index 00000000..63dab302
--- /dev/null
+++ b/app/Controller/BoardPopover.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Board Popover
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class BoardPopover extends Base
+{
+ /**
+ * Change a task assignee directly from the board
+ *
+ * @access public
+ */
+ public function changeAssignee()
+ {
+ $task = $this->getTask();
+ $project = $this->project->getById($task['project_id']);
+
+ $this->response->html($this->template->render('board/popover_assignee', array(
+ 'values' => $task,
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id']),
+ 'project' => $project,
+ )));
+ }
+
+ /**
+ * Validate an assignee modification
+ *
+ * @access public
+ */
+ public function updateAssignee()
+ {
+ $values = $this->request->getValues();
+
+ list($valid, ) = $this->taskValidator->validateAssigneeModification($values);
+
+ if ($valid && $this->taskModification->update($values)) {
+ $this->flash->success(t('Task updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update your task.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id'])));
+ }
+
+ /**
+ * Change a task category directly from the board
+ *
+ * @access public
+ */
+ public function changeCategory()
+ {
+ $task = $this->getTask();
+ $project = $this->project->getById($task['project_id']);
+
+ $this->response->html($this->template->render('board/popover_category', array(
+ 'values' => $task,
+ 'categories_list' => $this->category->getList($project['id']),
+ 'project' => $project,
+ )));
+ }
+
+ /**
+ * Validate a category modification
+ *
+ * @access public
+ */
+ public function updateCategory()
+ {
+ $values = $this->request->getValues();
+
+ list($valid, ) = $this->taskValidator->validateCategoryModification($values);
+
+ if ($valid && $this->taskModification->update($values)) {
+ $this->flash->success(t('Task updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update your task.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id'])));
+ }
+
+ /**
+ * Screenshot popover
+ *
+ * @access public
+ */
+ public function screenshot()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->template->render('task_file/screenshot', array(
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Confirmation before to close all column tasks
+ *
+ * @access public
+ */
+ public function confirmCloseColumnTasks()
+ {
+ $project = $this->getProject();
+ $column_id = $this->request->getIntegerParam('column_id');
+ $swimlane_id = $this->request->getIntegerParam('swimlane_id');
+
+ $this->response->html($this->template->render('board/popover_close_all_tasks_column', array(
+ 'project' => $project,
+ 'nb_tasks' => $this->taskFinder->countByColumnAndSwimlaneId($project['id'], $column_id, $swimlane_id),
+ 'column' => $this->column->getColumnTitleById($column_id),
+ 'swimlane' => $this->swimlane->getNameById($swimlane_id) ?: t($project['default_swimlane']),
+ 'values' => array('column_id' => $column_id, 'swimlane_id' => $swimlane_id),
+ )));
+ }
+
+ /**
+ * Close all column tasks
+ *
+ * @access public
+ */
+ public function closeColumnTasks()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+
+ $this->taskStatus->closeTasksBySwimlaneAndColumn($values['swimlane_id'], $values['column_id']);
+ $this->flash->success(t('All tasks of the column "%s" and the swimlane "%s" have been closed successfully.', $this->column->getColumnTitleById($values['column_id']), $this->swimlane->getNameById($values['swimlane_id']) ?: t($project['default_swimlane'])));
+ $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project['id'])));
+ }
+}
diff --git a/app/Controller/BoardTooltip.php b/app/Controller/BoardTooltip.php
new file mode 100644
index 00000000..bc07ce09
--- /dev/null
+++ b/app/Controller/BoardTooltip.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Board Tooltip
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class BoardTooltip extends Base
+{
+ /**
+ * Get links on mouseover
+ *
+ * @access public
+ */
+ public function tasklinks()
+ {
+ $task = $this->getTask();
+ $this->response->html($this->template->render('board/tooltip_tasklinks', array(
+ 'links' => $this->taskLink->getAllGroupedByLabel($task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Get links on mouseover
+ *
+ * @access public
+ */
+ public function externallinks()
+ {
+ $task = $this->getTask();
+ $this->response->html($this->template->render('board/tooltip_external_links', array(
+ 'links' => $this->taskExternalLink->getAll($task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Get subtasks on mouseover
+ *
+ * @access public
+ */
+ public function subtasks()
+ {
+ $task = $this->getTask();
+ $this->response->html($this->template->render('board/tooltip_subtasks', array(
+ 'subtasks' => $this->subtask->getAll($task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Display all attachments during the task mouseover
+ *
+ * @access public
+ */
+ public function attachments()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->template->render('board/tooltip_files', array(
+ 'files' => $this->taskFile->getAll($task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Display comments during a task mouseover
+ *
+ * @access public
+ */
+ public function comments()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->template->render('board/tooltip_comments', array(
+ 'comments' => $this->comment->getAll($task['id'], $this->userSession->getCommentSorting())
+ )));
+ }
+
+ /**
+ * Display task description
+ *
+ * @access public
+ */
+ public function description()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->template->render('board/tooltip_description', array(
+ 'task' => $task
+ )));
+ }
+
+ /**
+ * Get recurrence information on mouseover
+ *
+ * @access public
+ */
+ public function recurrence()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->template->render('task_recurrence/info', array(
+ 'task' => $task,
+ 'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(),
+ 'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(),
+ 'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(),
+ )));
+ }
+
+ /**
+ * Display swimlane description in tooltip
+ *
+ * @access public
+ */
+ public function swimlane()
+ {
+ $this->getProject();
+ $swimlane = $this->swimlane->getById($this->request->getIntegerParam('swimlane_id'));
+ $this->response->html($this->template->render('board/tooltip_description', array('task' => $swimlane)));
+ }
+}
diff --git a/app/Controller/Calendar.php b/app/Controller/Calendar.php
index 67a402d3..a0a25e41 100644
--- a/app/Controller/Calendar.php
+++ b/app/Controller/Calendar.php
@@ -20,7 +20,7 @@ class Calendar extends Base
*/
public function show()
{
- $this->response->html($this->template->layout('calendar/show', array(
+ $this->response->html($this->helper->layout->app('calendar/show', array(
'check_interval' => $this->config->get('board_private_refresh_interval'),
) + $this->getProjectFilters('calendar', 'show')));
}
diff --git a/app/Controller/Captcha.php b/app/Controller/Captcha.php
new file mode 100644
index 00000000..fcf081ea
--- /dev/null
+++ b/app/Controller/Captcha.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Gregwar\Captcha\CaptchaBuilder;
+
+/**
+ * Captcha Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Captcha extends Base
+{
+ /**
+ * Display captcha image
+ *
+ * @access public
+ */
+ public function image()
+ {
+ $this->response->contentType('image/jpeg');
+
+ $builder = new CaptchaBuilder;
+ $builder->build();
+ $this->sessionStorage->captcha = $builder->getPhrase();
+ $builder->output();
+ }
+}
diff --git a/app/Controller/Category.php b/app/Controller/Category.php
index 4aefd9fe..258a3b78 100644
--- a/app/Controller/Category.php
+++ b/app/Controller/Category.php
@@ -22,7 +22,7 @@ class Category extends Base
$category = $this->category->getById($this->request->getIntegerParam('category_id'));
if (empty($category)) {
- $this->session->flashError(t('Category not found.'));
+ $this->flash->failure(t('Category not found.'));
$this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project_id)));
}
@@ -38,7 +38,7 @@ class Category extends Base
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('category/index', array(
+ $this->response->html($this->helper->layout->project('category/index', array(
'categories' => $this->category->getList($project['id'], false),
'values' => $values + array('project_id' => $project['id']),
'errors' => $errors,
@@ -57,14 +57,14 @@ class Category extends Base
$project = $this->getProject();
$values = $this->request->getValues();
- list($valid, $errors) = $this->category->validateCreation($values);
+ list($valid, $errors) = $this->categoryValidator->validateCreation($values);
if ($valid) {
if ($this->category->create($values)) {
- $this->session->flash(t('Your category have been created successfully.'));
+ $this->flash->success(t('Your category have been created successfully.'));
$this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to create your category.'));
+ $this->flash->failure(t('Unable to create your category.'));
}
}
@@ -81,7 +81,7 @@ class Category extends Base
$project = $this->getProject();
$category = $this->getCategory($project['id']);
- $this->response->html($this->projectLayout('category/edit', array(
+ $this->response->html($this->helper->layout->project('category/edit', array(
'values' => empty($values) ? $category : $values,
'errors' => $errors,
'project' => $project,
@@ -99,14 +99,14 @@ class Category extends Base
$project = $this->getProject();
$values = $this->request->getValues();
- list($valid, $errors) = $this->category->validateModification($values);
+ list($valid, $errors) = $this->categoryValidator->validateModification($values);
if ($valid) {
if ($this->category->update($values)) {
- $this->session->flash(t('Your category have been updated successfully.'));
+ $this->flash->success(t('Your category have been updated successfully.'));
$this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to update your category.'));
+ $this->flash->failure(t('Unable to update your category.'));
}
}
@@ -123,7 +123,7 @@ class Category extends Base
$project = $this->getProject();
$category = $this->getCategory($project['id']);
- $this->response->html($this->projectLayout('category/remove', array(
+ $this->response->html($this->helper->layout->project('category/remove', array(
'project' => $project,
'category' => $category,
'title' => t('Remove a category')
@@ -142,9 +142,9 @@ class Category extends Base
$category = $this->getCategory($project['id']);
if ($this->category->remove($category['id'])) {
- $this->session->flash(t('Category removed successfully.'));
+ $this->flash->success(t('Category removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this category.'));
+ $this->flash->failure(t('Unable to remove this category.'));
}
$this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project['id'])));
diff --git a/app/Controller/Column.php b/app/Controller/Column.php
index d28fb293..66073b56 100644
--- a/app/Controller/Column.php
+++ b/app/Controller/Column.php
@@ -15,20 +15,12 @@ class Column extends Base
*
* @access public
*/
- public function index(array $values = array(), array $errors = array())
+ public function index()
{
$project = $this->getProject();
- $columns = $this->board->getColumns($project['id']);
+ $columns = $this->column->getAll($project['id']);
- foreach ($columns as $column) {
- $values['title['.$column['id'].']'] = $column['title'];
- $values['description['.$column['id'].']'] = $column['description'];
- $values['task_limit['.$column['id'].']'] = $column['task_limit'] ?: null;
- }
-
- $this->response->html($this->projectLayout('column/index', array(
- 'errors' => $errors,
- 'values' => $values + array('project_id' => $project['id']),
+ $this->response->html($this->helper->layout->project('column/index', array(
'columns' => $columns,
'project' => $project,
'title' => t('Edit board')
@@ -36,33 +28,48 @@ class Column extends Base
}
/**
- * Validate and add a new column
+ * Show form to create a new column
*
* @access public
*/
- public function create()
+ public function create(array $values = array(), array $errors = array())
{
$project = $this->getProject();
- $columns = $this->board->getColumnsList($project['id']);
- $data = $this->request->getValues();
- $values = array();
- foreach ($columns as $column_id => $column_title) {
- $values['title['.$column_id.']'] = $column_title;
+ if (empty($values)) {
+ $values = array('project_id' => $project['id']);
}
- list($valid, $errors) = $this->board->validateCreation($data);
+ $this->response->html($this->template->render('column/create', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'title' => t('Add a new column')
+ )));
+ }
+
+ /**
+ * Validate and add a new column
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+
+ list($valid, $errors) = $this->columnValidator->validateCreation($values);
if ($valid) {
- if ($this->board->addColumn($project['id'], $data['title'], $data['task_limit'], $data['description'])) {
- $this->session->flash(t('Board updated successfully.'));
- $this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id'])));
+ if ($this->column->create($project['id'], $values['title'], $values['task_limit'], $values['description'])) {
+ $this->flash->success(t('Column created successfully.'));
+ return $this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id'])), true);
} else {
- $this->session->flashError(t('Unable to update this board.'));
+ $errors['title'] = array(t('Another column with the same name exists in the project'));
}
}
- $this->index($values, $errors);
+ $this->create($values, $errors);
}
/**
@@ -73,9 +80,9 @@ class Column extends Base
public function edit(array $values = array(), array $errors = array())
{
$project = $this->getProject();
- $column = $this->board->getColumn($this->request->getIntegerParam('column_id'));
+ $column = $this->column->getById($this->request->getIntegerParam('column_id'));
- $this->response->html($this->projectLayout('column/edit', array(
+ $this->response->html($this->helper->layout->project('column/edit', array(
'errors' => $errors,
'values' => $values ?: $column,
'project' => $project,
@@ -94,14 +101,14 @@ class Column extends Base
$project = $this->getProject();
$values = $this->request->getValues();
- list($valid, $errors) = $this->board->validateModification($values);
+ list($valid, $errors) = $this->columnValidator->validateModification($values);
if ($valid) {
- if ($this->board->updateColumn($values['id'], $values['title'], $values['task_limit'], $values['description'])) {
- $this->session->flash(t('Board updated successfully.'));
+ if ($this->column->update($values['id'], $values['title'], $values['task_limit'], $values['description'])) {
+ $this->flash->success(t('Board updated successfully.'));
$this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to update this board.'));
+ $this->flash->failure(t('Unable to update this board.'));
}
}
@@ -109,22 +116,21 @@ class Column extends Base
}
/**
- * Move a column up or down
+ * Move column position
*
* @access public
*/
public function move()
{
- $this->checkCSRFParam();
$project = $this->getProject();
- $column_id = $this->request->getIntegerParam('column_id');
- $direction = $this->request->getStringParam('direction');
+ $values = $this->request->getJson();
- if ($direction === 'up' || $direction === 'down') {
- $this->board->{'move'.$direction}($project['id'], $column_id);
+ if (! empty($values) && isset($values['column_id']) && isset($values['position'])) {
+ $result = $this->column->changePosition($project['id'], $values['column_id'], $values['position']);
+ return $this->response->json(array('result' => $result));
}
- $this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id'])));
+ $this->forbidden();
}
/**
@@ -136,8 +142,8 @@ class Column extends Base
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('column/remove', array(
- 'column' => $this->board->getColumn($this->request->getIntegerParam('column_id')),
+ $this->response->html($this->helper->layout->project('column/remove', array(
+ 'column' => $this->column->getById($this->request->getIntegerParam('column_id')),
'project' => $project,
'title' => t('Remove a column from a board')
)));
@@ -152,12 +158,12 @@ class Column extends Base
{
$project = $this->getProject();
$this->checkCSRFParam();
- $column = $this->board->getColumn($this->request->getIntegerParam('column_id'));
+ $column_id = $this->request->getIntegerParam('column_id');
- if (! empty($column) && $this->board->removeColumn($column['id'])) {
- $this->session->flash(t('Column removed successfully.'));
+ if ($this->column->remove($column_id)) {
+ $this->flash->success(t('Column removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this column.'));
+ $this->flash->failure(t('Unable to remove this column.'));
}
$this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id'])));
diff --git a/app/Controller/Comment.php b/app/Controller/Comment.php
index d6cbbf1e..da3213e0 100644
--- a/app/Controller/Comment.php
+++ b/app/Controller/Comment.php
@@ -21,13 +21,11 @@ class Comment extends Base
$comment = $this->comment->getById($this->request->getIntegerParam('comment_id'));
if (empty($comment)) {
- $this->notfound();
+ return $this->notfound();
}
if (! $this->userSession->isAdmin() && $comment['user_id'] != $this->userSession->getId()) {
- $this->response->html($this->template->layout('comment/forbidden', array(
- 'title' => t('Access Forbidden')
- )));
+ return $this->forbidden();
}
return $comment;
@@ -41,7 +39,6 @@ class Comment extends Base
public function create(array $values = array(), array $errors = array())
{
$task = $this->getTask();
- $ajax = $this->request->isAjax() || $this->request->getIntegerParam('ajax');
if (empty($values)) {
$values = array(
@@ -50,16 +47,7 @@ class Comment extends Base
);
}
- if ($ajax) {
- $this->response->html($this->template->render('comment/create', array(
- 'values' => $values,
- 'errors' => $errors,
- 'task' => $task,
- 'ajax' => $ajax,
- )));
- }
-
- $this->response->html($this->taskLayout('comment/create', array(
+ $this->response->html($this->helper->layout->task('comment/create', array(
'values' => $values,
'errors' => $errors,
'task' => $task,
@@ -76,22 +64,17 @@ class Comment extends Base
{
$task = $this->getTask();
$values = $this->request->getValues();
- $ajax = $this->request->isAjax() || $this->request->getIntegerParam('ajax');
- list($valid, $errors) = $this->comment->validateCreation($values);
+ list($valid, $errors) = $this->commentValidator->validateCreation($values);
if ($valid) {
if ($this->comment->create($values)) {
- $this->session->flash(t('Comment added successfully.'));
+ $this->flash->success(t('Comment added successfully.'));
} else {
- $this->session->flashError(t('Unable to create your comment.'));
+ $this->flash->failure(t('Unable to create your comment.'));
}
- if ($ajax) {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'comments'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'comments'), true);
}
$this->create($values, $errors);
@@ -107,7 +90,7 @@ class Comment extends Base
$task = $this->getTask();
$comment = $this->getComment();
- $this->response->html($this->taskLayout('comment/edit', array(
+ $this->response->html($this->helper->layout->task('comment/edit', array(
'values' => empty($values) ? $comment : $values,
'errors' => $errors,
'comment' => $comment,
@@ -124,19 +107,19 @@ class Comment extends Base
public function update()
{
$task = $this->getTask();
- $comment = $this->getComment();
+ $this->getComment();
$values = $this->request->getValues();
- list($valid, $errors) = $this->comment->validateModification($values);
+ list($valid, $errors) = $this->commentValidator->validateModification($values);
if ($valid) {
if ($this->comment->update($values)) {
- $this->session->flash(t('Comment updated successfully.'));
+ $this->flash->success(t('Comment updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update your comment.'));
+ $this->flash->failure(t('Unable to update your comment.'));
}
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'comment-'.$comment['id']));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), false);
}
$this->edit($values, $errors);
@@ -152,7 +135,7 @@ class Comment extends Base
$task = $this->getTask();
$comment = $this->getComment();
- $this->response->html($this->taskLayout('comment/remove', array(
+ $this->response->html($this->helper->layout->task('comment/remove', array(
'comment' => $comment,
'task' => $task,
'title' => t('Remove a comment')
@@ -171,9 +154,9 @@ class Comment extends Base
$comment = $this->getComment();
if ($this->comment->remove($comment['id'])) {
- $this->session->flash(t('Comment removed successfully.'));
+ $this->flash->success(t('Comment removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this comment.'));
+ $this->flash->failure(t('Unable to remove this comment.'));
}
$this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'comments'));
diff --git a/app/Controller/Config.php b/app/Controller/Config.php
index 47b844e4..e811f870 100644
--- a/app/Controller/Config.php
+++ b/app/Controller/Config.php
@@ -11,24 +11,6 @@ namespace Kanboard\Controller;
class Config extends Base
{
/**
- * Common layout for config views
- *
- * @access private
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- private function layout($template, array $params)
- {
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['values'] = $this->config->getAll();
- $params['errors'] = array();
- $params['config_content_for_layout'] = $this->template->render($template, $params);
-
- return $this->template->layout('config/layout', $params);
- }
-
- /**
* Common method between pages
*
* @access private
@@ -40,8 +22,16 @@ class Config extends Base
$values = $this->request->getValues();
switch ($redirect) {
+ case 'application':
+ $values += array('password_reset' => 0);
+ break;
case 'project':
- $values += array('subtask_restriction' => 0, 'subtask_time_tracking' => 0, 'cfd_include_closed_tasks' => 0);
+ $values += array(
+ 'subtask_restriction' => 0,
+ 'subtask_time_tracking' => 0,
+ 'cfd_include_closed_tasks' => 0,
+ 'disable_private_project' => 0,
+ );
break;
case 'integrations':
$values += array('integration_gravatar' => 0);
@@ -53,9 +43,9 @@ class Config extends Base
if ($this->config->save($values)) {
$this->config->reload();
- $this->session->flash(t('Settings saved successfully.'));
+ $this->flash->success(t('Settings saved successfully.'));
} else {
- $this->session->flashError(t('Unable to save your settings.'));
+ $this->flash->failure(t('Unable to save your settings.'));
}
$this->response->redirect($this->helper->url->to('config', $redirect));
@@ -69,7 +59,7 @@ class Config extends Base
*/
public function index()
{
- $this->response->html($this->layout('config/about', array(
+ $this->response->html($this->helper->layout->config('config/about', array(
'db_size' => $this->config->getDatabaseSize(),
'title' => t('Settings').' &gt; '.t('About'),
)));
@@ -82,7 +72,7 @@ class Config extends Base
*/
public function plugins()
{
- $this->response->html($this->layout('config/plugins', array(
+ $this->response->html($this->helper->layout->config('config/plugins', array(
'plugins' => $this->pluginLoader->plugins,
'title' => t('Settings').' &gt; '.t('Plugins'),
)));
@@ -97,10 +87,12 @@ class Config extends Base
{
$this->common('application');
- $this->response->html($this->layout('config/application', array(
+ $this->response->html($this->helper->layout->config('config/application', array(
'languages' => $this->config->getLanguages(),
'timezones' => $this->config->getTimezones(),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
+ 'date_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateFormats()),
+ 'datetime_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateTimeFormats()),
+ 'time_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getTimeFormats()),
'title' => t('Settings').' &gt; '.t('Application settings'),
)));
}
@@ -114,7 +106,7 @@ class Config extends Base
{
$this->common('project');
- $this->response->html($this->layout('config/project', array(
+ $this->response->html($this->helper->layout->config('config/project', array(
'colors' => $this->color->getList(),
'default_columns' => implode(', ', $this->board->getDefaultColumns()),
'title' => t('Settings').' &gt; '.t('Project settings'),
@@ -130,7 +122,7 @@ class Config extends Base
{
$this->common('board');
- $this->response->html($this->layout('config/board', array(
+ $this->response->html($this->helper->layout->config('config/board', array(
'title' => t('Settings').' &gt; '.t('Board settings'),
)));
}
@@ -144,7 +136,7 @@ class Config extends Base
{
$this->common('calendar');
- $this->response->html($this->layout('config/calendar', array(
+ $this->response->html($this->helper->layout->config('config/calendar', array(
'title' => t('Settings').' &gt; '.t('Calendar settings'),
)));
}
@@ -158,7 +150,7 @@ class Config extends Base
{
$this->common('integrations');
- $this->response->html($this->layout('config/integrations', array(
+ $this->response->html($this->helper->layout->config('config/integrations', array(
'title' => t('Settings').' &gt; '.t('Integrations'),
)));
}
@@ -172,7 +164,7 @@ class Config extends Base
{
$this->common('webhook');
- $this->response->html($this->layout('config/webhook', array(
+ $this->response->html($this->helper->layout->config('config/webhook', array(
'title' => t('Settings').' &gt; '.t('Webhook settings'),
)));
}
@@ -184,7 +176,7 @@ class Config extends Base
*/
public function api()
{
- $this->response->html($this->layout('config/api', array(
+ $this->response->html($this->helper->layout->config('config/api', array(
'title' => t('Settings').' &gt; '.t('API'),
)));
}
@@ -210,7 +202,7 @@ class Config extends Base
{
$this->checkCSRFParam();
$this->config->optimizeDatabase();
- $this->session->flash(t('Database optimization done.'));
+ $this->flash->success(t('Database optimization done.'));
$this->response->redirect($this->helper->url->to('config', 'index'));
}
@@ -226,7 +218,7 @@ class Config extends Base
$this->checkCSRFParam();
$this->config->regenerateToken($type.'_token');
- $this->session->flash(t('Token regenerated.'));
+ $this->flash->success(t('Token regenerated.'));
$this->response->redirect($this->helper->url->to('config', $type));
}
}
diff --git a/app/Controller/Currency.php b/app/Controller/Currency.php
index 9d6b0249..ecaa9834 100644
--- a/app/Controller/Currency.php
+++ b/app/Controller/Currency.php
@@ -11,34 +11,18 @@ namespace Kanboard\Controller;
class Currency extends Base
{
/**
- * Common layout for config views
- *
- * @access private
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- private function layout($template, array $params)
- {
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['config_content_for_layout'] = $this->template->render($template, $params);
-
- return $this->template->layout('config/layout', $params);
- }
-
- /**
* Display all currency rates and form
*
* @access public
*/
public function index(array $values = array(), array $errors = array())
{
- $this->response->html($this->layout('currency/index', array(
+ $this->response->html($this->helper->layout->config('currency/index', array(
'config_values' => array('application_currency' => $this->config->get('application_currency')),
'values' => $values,
'errors' => $errors,
'rates' => $this->currency->getAll(),
- 'currencies' => $this->config->getCurrencies(),
+ 'currencies' => $this->currency->getCurrencies(),
'title' => t('Settings').' &gt; '.t('Currency rates'),
)));
}
@@ -51,14 +35,14 @@ class Currency extends Base
public function create()
{
$values = $this->request->getValues();
- list($valid, $errors) = $this->currency->validate($values);
+ list($valid, $errors) = $this->currencyValidator->validateCreation($values);
if ($valid) {
if ($this->currency->create($values['currency'], $values['rate'])) {
- $this->session->flash(t('The currency rate have been added successfully.'));
+ $this->flash->success(t('The currency rate have been added successfully.'));
$this->response->redirect($this->helper->url->to('currency', 'index'));
} else {
- $this->session->flashError(t('Unable to add this currency rate.'));
+ $this->flash->failure(t('Unable to add this currency rate.'));
}
}
@@ -76,9 +60,9 @@ class Currency extends Base
if ($this->config->save($values)) {
$this->config->reload();
- $this->session->flash(t('Settings saved successfully.'));
+ $this->flash->success(t('Settings saved successfully.'));
} else {
- $this->session->flashError(t('Unable to save your settings.'));
+ $this->flash->failure(t('Unable to save your settings.'));
}
$this->response->redirect($this->helper->url->to('currency', 'index'));
diff --git a/app/Controller/Customfilter.php b/app/Controller/Customfilter.php
index a152c668..41da0b11 100644
--- a/app/Controller/Customfilter.php
+++ b/app/Controller/Customfilter.php
@@ -2,6 +2,8 @@
namespace Kanboard\Controller;
+use Kanboard\Core\Security\Role;
+
/**
* Custom Filter management
*
@@ -19,7 +21,7 @@ class Customfilter extends Base
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('custom_filter/index', array(
+ $this->response->html($this->helper->layout->project('custom_filter/index', array(
'values' => $values + array('project_id' => $project['id']),
'errors' => $errors,
'project' => $project,
@@ -40,14 +42,14 @@ class Customfilter extends Base
$values = $this->request->getValues();
$values['user_id'] = $this->userSession->getId();
- list($valid, $errors) = $this->customFilter->validateCreation($values);
+ list($valid, $errors) = $this->customFilterValidator->validateCreation($values);
if ($valid) {
if ($this->customFilter->create($values)) {
- $this->session->flash(t('Your custom filter have been created successfully.'));
+ $this->flash->success(t('Your custom filter have been created successfully.'));
$this->response->redirect($this->helper->url->to('customfilter', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to create your custom filter.'));
+ $this->flash->failure(t('Unable to create your custom filter.'));
}
}
@@ -55,6 +57,23 @@ class Customfilter extends Base
}
/**
+ * Confirmation dialog before removing a custom filter
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $project = $this->getProject();
+ $filter = $this->customFilter->getById($this->request->getIntegerParam('filter_id'));
+
+ $this->response->html($this->helper->layout->project('custom_filter/remove', array(
+ 'project' => $project,
+ 'filter' => $filter,
+ 'title' => t('Remove a custom filter')
+ )));
+ }
+
+ /**
* Remove a custom filter
*
* @access public
@@ -68,9 +87,9 @@ class Customfilter extends Base
$this->checkPermission($project, $filter);
if ($this->customFilter->remove($filter['id'])) {
- $this->session->flash(t('Custom filter removed successfully.'));
+ $this->flash->success(t('Custom filter removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this custom filter.'));
+ $this->flash->failure(t('Unable to remove this custom filter.'));
}
$this->response->redirect($this->helper->url->to('customfilter', 'index', array('project_id' => $project['id'])));
@@ -88,7 +107,7 @@ class Customfilter extends Base
$this->checkPermission($project, $filter);
- $this->response->html($this->projectLayout('custom_filter/edit', array(
+ $this->response->html($this->helper->layout->project('custom_filter/edit', array(
'values' => empty($values) ? $filter : $values,
'errors' => $errors,
'project' => $project,
@@ -119,14 +138,14 @@ class Customfilter extends Base
$values += array('append' => 0);
}
- list($valid, $errors) = $this->customFilter->validateModification($values);
+ list($valid, $errors) = $this->customFilterValidator->validateModification($values);
if ($valid) {
if ($this->customFilter->update($values)) {
- $this->session->flash(t('Your custom filter have been updated successfully.'));
+ $this->flash->success(t('Your custom filter have been updated successfully.'));
$this->response->redirect($this->helper->url->to('customfilter', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to update custom filter.'));
+ $this->flash->failure(t('Unable to update custom filter.'));
}
}
@@ -137,7 +156,7 @@ class Customfilter extends Base
{
$user_id = $this->userSession->getId();
- if ($filter['user_id'] != $user_id && (! $this->projectPermission->isManager($project['id'], $user_id) || ! $this->userSession->isAdmin())) {
+ if ($filter['user_id'] != $user_id && ($this->projectUserRole->getUserRole($project['id'], $user_id) === Role::PROJECT_MANAGER || ! $this->userSession->isAdmin())) {
$this->forbidden();
}
}
diff --git a/app/Controller/Doc.php b/app/Controller/Doc.php
index 32413048..6f309d48 100644
--- a/app/Controller/Doc.php
+++ b/app/Controller/Doc.php
@@ -52,8 +52,6 @@ class Doc extends Base
}
}
- $this->response->html($this->template->layout('doc/show', $this->readFile($filename) + array(
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
- )));
+ $this->response->html($this->helper->layout->app('doc/show', $this->readFile($filename)));
}
}
diff --git a/app/Controller/Export.php b/app/Controller/Export.php
index cdedcb88..726edd42 100644
--- a/app/Controller/Export.php
+++ b/app/Controller/Export.php
@@ -27,7 +27,7 @@ class Export extends Base
$this->response->csv($data);
}
- $this->response->html($this->projectLayout('export/'.$action, array(
+ $this->response->html($this->helper->layout->project('export/'.$action, array(
'values' => array(
'controller' => 'export',
'action' => $action,
@@ -37,7 +37,7 @@ class Export extends Base
),
'errors' => array(),
'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
+ 'date_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateFormats()),
'project' => $project,
'title' => $page_title,
), 'export/sidebar'));
@@ -70,7 +70,7 @@ class Export extends Base
*/
public function summary()
{
- $this->common('ProjectDailyColumnStats', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export'));
+ $this->common('projectDailyColumnStats', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export'));
}
/**
diff --git a/app/Controller/Feed.php b/app/Controller/Feed.php
index 95b81fb8..8457c383 100644
--- a/app/Controller/Feed.php
+++ b/app/Controller/Feed.php
@@ -25,10 +25,8 @@ class Feed extends Base
$this->forbidden(true);
}
- $projects = $this->projectPermission->getActiveMemberProjects($user['id']);
-
$this->response->xml($this->template->render('feed/user', array(
- 'events' => $this->projectActivity->getProjects(array_keys($projects)),
+ 'events' => $this->projectActivity->getProjects($this->projectPermission->getActiveProjectIds($user['id'])),
'user' => $user,
)));
}
diff --git a/app/Controller/File.php b/app/Controller/File.php
deleted file mode 100644
index 4d771e2f..00000000
--- a/app/Controller/File.php
+++ /dev/null
@@ -1,192 +0,0 @@
-<?php
-
-namespace Kanboard\Controller;
-
-use Kanboard\Core\ObjectStorage\ObjectStorageException;
-
-/**
- * File controller
- *
- * @package controller
- * @author Frederic Guillot
- */
-class File extends Base
-{
- /**
- * Screenshot
- *
- * @access public
- */
- public function screenshot()
- {
- $task = $this->getTask();
-
- if ($this->request->isPost() && $this->file->uploadScreenshot($task['project_id'], $task['id'], $this->request->getValue('screenshot')) !== false) {
- $this->session->flash(t('Screenshot uploaded successfully.'));
-
- if ($this->request->getStringParam('redirect') === 'board') {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
-
- $this->response->html($this->taskLayout('file/screenshot', array(
- 'task' => $task,
- 'redirect' => 'task',
- )));
- }
-
- /**
- * File upload form
- *
- * @access public
- */
- public function create()
- {
- $task = $this->getTask();
-
- $this->response->html($this->taskLayout('file/new', array(
- 'task' => $task,
- 'max_size' => ini_get('upload_max_filesize'),
- )));
- }
-
- /**
- * File upload (save files)
- *
- * @access public
- */
- public function save()
- {
- $task = $this->getTask();
-
- if (! $this->file->uploadFiles($task['project_id'], $task['id'], 'files')) {
- $this->session->flashError(t('Unable to upload the file.'));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
-
- /**
- * File download
- *
- * @access public
- */
- public function download()
- {
- try {
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
-
- if ($file['task_id'] != $task['id']) {
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
-
- $this->response->forceDownload($file['name']);
- $this->objectStorage->output($file['path']);
- } catch (ObjectStorageException $e) {
- $this->logger->error($e->getMessage());
- }
- }
-
- /**
- * Open a file (show the content in a popover)
- *
- * @access public
- */
- public function open()
- {
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
-
- if ($file['task_id'] == $task['id']) {
- $this->response->html($this->template->render('file/open', array(
- 'file' => $file,
- 'task' => $task,
- )));
- }
- }
-
- /**
- * Display image
- *
- * @access public
- */
- public function image()
- {
- try {
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
-
- if ($file['task_id'] == $task['id']) {
- $this->response->contentType($this->file->getImageMimeType($file['name']));
- $this->objectStorage->output($file['path']);
- }
- } catch (ObjectStorageException $e) {
- $this->logger->error($e->getMessage());
- }
- }
-
- /**
- * Display image thumbnails
- *
- * @access public
- */
- public function thumbnail()
- {
- $this->response->contentType('image/jpeg');
-
- try {
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
-
- if ($file['task_id'] == $task['id']) {
- $this->objectStorage->output($this->file->getThumbnailPath($file['path']));
- }
- } catch (ObjectStorageException $e) {
- $this->logger->error($e->getMessage());
-
- // Try to generate thumbnail on the fly for images uploaded before Kanboard < 1.0.19
- $data = $this->objectStorage->get($file['path']);
- $this->file->generateThumbnailFromData($file['path'], $data);
- $this->objectStorage->output($this->file->getThumbnailPath($file['path']));
- }
- }
-
- /**
- * Remove a file
- *
- * @access public
- */
- public function remove()
- {
- $this->checkCSRFParam();
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
-
- if ($file['task_id'] == $task['id'] && $this->file->remove($file['id'])) {
- $this->session->flash(t('File removed successfully.'));
- } else {
- $this->session->flashError(t('Unable to remove this file.'));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
-
- /**
- * Confirmation dialog before removing a file
- *
- * @access public
- */
- public function confirm()
- {
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
-
- $this->response->html($this->taskLayout('file/remove', array(
- 'task' => $task,
- 'file' => $file,
- )));
- }
-}
diff --git a/app/Controller/FileViewer.php b/app/Controller/FileViewer.php
new file mode 100644
index 00000000..bc91c3d8
--- /dev/null
+++ b/app/Controller/FileViewer.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Kanboard\Core\ObjectStorage\ObjectStorageException;
+
+/**
+ * File Viewer Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class FileViewer extends Base
+{
+ /**
+ * Get file content from object storage
+ *
+ * @access private
+ * @param array $file
+ * @return string
+ */
+ private function getFileContent(array $file)
+ {
+ $content = '';
+
+ try {
+
+ if ($file['is_image'] == 0) {
+ $content = $this->objectStorage->get($file['path']);
+ }
+
+ } catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
+
+ return $content;
+ }
+
+ /**
+ * Show file content in a popover
+ *
+ * @access public
+ */
+ public function show()
+ {
+ $file = $this->getFile();
+ $type = $this->helper->file->getPreviewType($file['name']);
+ $params = array('file_id' => $file['id'], 'project_id' => $this->request->getIntegerParam('project_id'));
+
+ if ($file['model'] === 'taskFile') {
+ $params['task_id'] = $file['task_id'];
+ }
+
+ $this->response->html($this->template->render('file_viewer/show', array(
+ 'file' => $file,
+ 'params' => $params,
+ 'type' => $type,
+ 'content' => $this->getFileContent($file),
+ )));
+ }
+
+ /**
+ * Display image
+ *
+ * @access public
+ */
+ public function image()
+ {
+ try {
+ $file = $this->getFile();
+ $this->response->contentType($this->helper->file->getImageMimeType($file['name']));
+ $this->objectStorage->output($file['path']);
+ } catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Display image thumbnail
+ *
+ * @access public
+ */
+ public function thumbnail()
+ {
+ $this->response->contentType('image/jpeg');
+
+ try {
+ $file = $this->getFile();
+ $model = $file['model'];
+ $this->objectStorage->output($this->$model->getThumbnailPath($file['path']));
+ } catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+
+ // Try to generate thumbnail on the fly for images uploaded before Kanboard < 1.0.19
+ $data = $this->objectStorage->get($file['path']);
+ $this->$model->generateThumbnailFromData($file['path'], $data);
+ $this->objectStorage->output($this->$model->getThumbnailPath($file['path']));
+ }
+ }
+
+ /**
+ * File download
+ *
+ * @access public
+ */
+ public function download()
+ {
+ try {
+ $file = $this->getFile();
+ $this->response->forceDownload($file['name']);
+ $this->objectStorage->output($file['path']);
+ } catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
+ }
+}
diff --git a/app/Controller/Gantt.php b/app/Controller/Gantt.php
index 24d94f02..9ffa277f 100644
--- a/app/Controller/Gantt.php
+++ b/app/Controller/Gantt.php
@@ -20,13 +20,12 @@ class Gantt extends Base
if ($this->userSession->isAdmin()) {
$project_ids = $this->project->getAllIds();
} else {
- $project_ids = $this->projectPermission->getMemberProjectIds($this->userSession->getId());
+ $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId());
}
- $this->response->html($this->template->layout('gantt/projects', array(
+ $this->response->html($this->helper->layout->app('gantt/projects', array(
'projects' => $this->projectGanttFormatter->filter($project_ids)->format(),
'title' => t('Gantt chart for all projects'),
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
)));
}
@@ -65,8 +64,8 @@ class Gantt extends Base
$filter->getQuery()->asc('column_position')->asc(TaskModel::TABLE.'.position');
}
- $this->response->html($this->template->layout('gantt/project', $params + array(
- 'users_list' => $this->projectPermission->getMemberList($params['project']['id'], false),
+ $this->response->html($this->helper->layout->app('gantt/project', $params + array(
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($params['project']['id'], false),
'sorting' => $sorting,
'tasks' => $filter->format(),
)));
@@ -102,19 +101,23 @@ class Gantt extends Base
{
$project = $this->getProject();
+ $values = $values + array(
+ 'project_id' => $project['id'],
+ 'column_id' => $this->column->getFirstColumnId($project['id']),
+ 'position' => 1
+ );
+
+ $values = $this->hook->merge('controller:task:form:default', $values, array('default_values' => $values));
+ $values = $this->hook->merge('controller:gantt:task:form:default', $values, array('default_values' => $values));
+
$this->response->html($this->template->render('gantt/task_creation', array(
+ 'project' => $project,
'errors' => $errors,
- 'values' => $values + array(
- 'project_id' => $project['id'],
- 'column_id' => $this->board->getFirstColumn($project['id']),
- 'position' => 1
- ),
- 'users_list' => $this->projectPermission->getMemberList($project['id'], true, false, true),
+ 'values' => $values,
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id'], true, false, true),
'colors_list' => $this->color->getList(),
'categories_list' => $this->category->getList($project['id']),
'swimlanes_list' => $this->swimlane->getList($project['id'], false, true),
- 'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
'title' => $project['name'].' &gt; '.t('New task')
)));
}
@@ -135,10 +138,10 @@ class Gantt extends Base
$task_id = $this->taskCreation->create($values);
if ($task_id !== false) {
- $this->session->flash(t('Task created successfully.'));
+ $this->flash->success(t('Task created successfully.'));
$this->response->redirect($this->helper->url->to('gantt', 'project', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to create your task.'));
+ $this->flash->failure(t('Unable to create your task.'));
}
}
diff --git a/app/Controller/Group.php b/app/Controller/Group.php
new file mode 100644
index 00000000..fa47f428
--- /dev/null
+++ b/app/Controller/Group.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Group Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Group extends Base
+{
+ /**
+ * List all groups
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $paginator = $this->paginator
+ ->setUrl('group', 'index')
+ ->setMax(30)
+ ->setOrder('name')
+ ->setQuery($this->group->getQuery())
+ ->calculate();
+
+ $this->response->html($this->helper->layout->app('group/index', array(
+ 'title' => t('Groups').' ('.$paginator->getTotal().')',
+ 'paginator' => $paginator,
+ )));
+ }
+
+ /**
+ * List all users
+ *
+ * @access public
+ */
+ public function users()
+ {
+ $group_id = $this->request->getIntegerParam('group_id');
+ $group = $this->group->getById($group_id);
+
+ $paginator = $this->paginator
+ ->setUrl('group', 'users', array('group_id' => $group_id))
+ ->setMax(30)
+ ->setOrder('username')
+ ->setQuery($this->groupMember->getQuery($group_id))
+ ->calculate();
+
+ $this->response->html($this->helper->layout->app('group/users', array(
+ 'title' => t('Members of %s', $group['name']).' ('.$paginator->getTotal().')',
+ 'paginator' => $paginator,
+ 'group' => $group,
+ )));
+ }
+
+ /**
+ * Display a form to create a new group
+ *
+ * @access public
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $this->response->html($this->helper->layout->app('group/create', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'title' => t('New group')
+ )));
+ }
+
+ /**
+ * Validate and save a new group
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->groupValidator->validateCreation($values);
+
+ if ($valid) {
+ if ($this->group->create($values['name']) !== false) {
+ $this->flash->success(t('Group created successfully.'));
+ $this->response->redirect($this->helper->url->to('group', 'index'));
+ } else {
+ $this->flash->failure(t('Unable to create your group.'));
+ }
+ }
+
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Display a form to update a group
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ if (empty($values)) {
+ $values = $this->group->getById($this->request->getIntegerParam('group_id'));
+ }
+
+ $this->response->html($this->helper->layout->app('group/edit', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'title' => t('Edit group')
+ )));
+ }
+
+ /**
+ * Validate and save a group
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->groupValidator->validateModification($values);
+
+ if ($valid) {
+ if ($this->group->update($values) !== false) {
+ $this->flash->success(t('Group updated successfully.'));
+ $this->response->redirect($this->helper->url->to('group', 'index'));
+ } else {
+ $this->flash->failure(t('Unable to update your group.'));
+ }
+ }
+
+ $this->edit($values, $errors);
+ }
+
+ /**
+ * Form to associate a user to a group
+ *
+ * @access public
+ */
+ public function associate(array $values = array(), array $errors = array())
+ {
+ $group_id = $this->request->getIntegerParam('group_id');
+ $group = $this->group->getbyId($group_id);
+
+ if (empty($values)) {
+ $values['group_id'] = $group_id;
+ }
+
+ $this->response->html($this->helper->layout->app('group/associate', array(
+ 'users' => $this->user->prepareList($this->groupMember->getNotMembers($group_id)),
+ 'group' => $group,
+ 'errors' => $errors,
+ 'values' => $values,
+ 'title' => t('Add group member to "%s"', $group['name']),
+ )));
+ }
+
+ /**
+ * Add user to a group
+ *
+ * @access public
+ */
+ public function addUser()
+ {
+ $values = $this->request->getValues();
+
+ if (isset($values['group_id']) && isset($values['user_id'])) {
+ if ($this->groupMember->addUser($values['group_id'], $values['user_id'])) {
+ $this->flash->success(t('Group member added successfully.'));
+ $this->response->redirect($this->helper->url->to('group', 'users', array('group_id' => $values['group_id'])));
+ } else {
+ $this->flash->failure(t('Unable to add group member.'));
+ }
+ }
+
+ $this->associate($values);
+ }
+
+ /**
+ * Confirmation dialog to remove a user from a group
+ *
+ * @access public
+ */
+ public function dissociate()
+ {
+ $group_id = $this->request->getIntegerParam('group_id');
+ $user_id = $this->request->getIntegerParam('user_id');
+ $group = $this->group->getById($group_id);
+ $user = $this->user->getById($user_id);
+
+ $this->response->html($this->helper->layout->app('group/dissociate', array(
+ 'group' => $group,
+ 'user' => $user,
+ 'title' => t('Remove user from group "%s"', $group['name']),
+ )));
+ }
+
+ /**
+ * Remove a user from a group
+ *
+ * @access public
+ */
+ public function removeUser()
+ {
+ $this->checkCSRFParam();
+ $group_id = $this->request->getIntegerParam('group_id');
+ $user_id = $this->request->getIntegerParam('user_id');
+
+ if ($this->groupMember->removeUser($group_id, $user_id)) {
+ $this->flash->success(t('User removed successfully from this group.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this user from the group.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('group', 'users', array('group_id' => $group_id)));
+ }
+
+ /**
+ * Confirmation dialog to remove a group
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $group_id = $this->request->getIntegerParam('group_id');
+ $group = $this->group->getById($group_id);
+
+ $this->response->html($this->helper->layout->app('group/remove', array(
+ 'group' => $group,
+ 'title' => t('Remove group'),
+ )));
+ }
+
+ /**
+ * Remove a group
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $group_id = $this->request->getIntegerParam('group_id');
+
+ if ($this->group->remove($group_id)) {
+ $this->flash->success(t('Group removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this group.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('group', 'index'));
+ }
+}
diff --git a/app/Controller/GroupHelper.php b/app/Controller/GroupHelper.php
new file mode 100644
index 00000000..34f522a6
--- /dev/null
+++ b/app/Controller/GroupHelper.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Group Helper
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class GroupHelper extends Base
+{
+ /**
+ * Group autocompletion (Ajax)
+ *
+ * @access public
+ */
+ public function autocomplete()
+ {
+ $search = $this->request->getStringParam('term');
+ $groups = $this->groupManager->find($search);
+ $this->response->json($this->groupAutoCompleteFormatter->setGroups($groups)->format());
+ }
+}
diff --git a/app/Controller/Ical.php b/app/Controller/Ical.php
index f8e9e25f..f1ea6d8f 100644
--- a/app/Controller/Ical.php
+++ b/app/Controller/Ical.php
@@ -3,6 +3,7 @@
namespace Kanboard\Controller;
use Kanboard\Model\TaskFilter;
+use Kanboard\Model\Task as TaskModel;
use Eluceo\iCal\Component\Calendar as iCalendar;
/**
@@ -31,6 +32,7 @@ class Ical extends Base
// Common filter
$filter = $this->taskFilterICalendarFormatter
->create()
+ ->filterByStatus(TaskModel::STATUS_OPEN)
->filterByOwner($user['id']);
// Calendar properties
@@ -60,6 +62,7 @@ class Ical extends Base
// Common filter
$filter = $this->taskFilterICalendarFormatter
->create()
+ ->filterByStatus(TaskModel::STATUS_OPEN)
->filterByProject($project['id']);
// Calendar properties
diff --git a/app/Controller/Link.php b/app/Controller/Link.php
index 0eb3d679..ec7ab1af 100644
--- a/app/Controller/Link.php
+++ b/app/Controller/Link.php
@@ -12,22 +12,6 @@ namespace Kanboard\Controller;
class Link extends Base
{
/**
- * Common layout for config views
- *
- * @access private
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- private function layout($template, array $params)
- {
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['config_content_for_layout'] = $this->template->render($template, $params);
-
- return $this->template->layout('config/layout', $params);
- }
-
- /**
* Get the current link
*
* @access private
@@ -51,7 +35,7 @@ class Link extends Base
*/
public function index(array $values = array(), array $errors = array())
{
- $this->response->html($this->layout('link/index', array(
+ $this->response->html($this->helper->layout->config('link/index', array(
'links' => $this->link->getMergedList(),
'values' => $values,
'errors' => $errors,
@@ -67,14 +51,14 @@ class Link extends Base
public function save()
{
$values = $this->request->getValues();
- list($valid, $errors) = $this->link->validateCreation($values);
+ list($valid, $errors) = $this->linkValidator->validateCreation($values);
if ($valid) {
if ($this->link->create($values['label'], $values['opposite_label']) !== false) {
- $this->session->flash(t('Link added successfully.'));
+ $this->flash->success(t('Link added successfully.'));
$this->response->redirect($this->helper->url->to('link', 'index'));
} else {
- $this->session->flashError(t('Unable to create your link.'));
+ $this->flash->failure(t('Unable to create your link.'));
}
}
@@ -91,7 +75,7 @@ class Link extends Base
$link = $this->getLink();
$link['label'] = t($link['label']);
- $this->response->html($this->layout('link/edit', array(
+ $this->response->html($this->helper->layout->config('link/edit', array(
'values' => $values ?: $link,
'errors' => $errors,
'labels' => $this->link->getList($link['id']),
@@ -108,14 +92,14 @@ class Link extends Base
public function update()
{
$values = $this->request->getValues();
- list($valid, $errors) = $this->link->validateModification($values);
+ list($valid, $errors) = $this->linkValidator->validateModification($values);
if ($valid) {
if ($this->link->update($values)) {
- $this->session->flash(t('Link updated successfully.'));
+ $this->flash->success(t('Link updated successfully.'));
$this->response->redirect($this->helper->url->to('link', 'index'));
} else {
- $this->session->flashError(t('Unable to update your link.'));
+ $this->flash->failure(t('Unable to update your link.'));
}
}
@@ -131,7 +115,7 @@ class Link extends Base
{
$link = $this->getLink();
- $this->response->html($this->layout('link/remove', array(
+ $this->response->html($this->helper->layout->config('link/remove', array(
'link' => $link,
'title' => t('Remove a link')
)));
@@ -148,9 +132,9 @@ class Link extends Base
$link = $this->getLink();
if ($this->link->remove($link['id'])) {
- $this->session->flash(t('Link removed successfully.'));
+ $this->flash->success(t('Link removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this link.'));
+ $this->flash->failure(t('Unable to remove this link.'));
}
$this->response->redirect($this->helper->url->to('link', 'index'));
diff --git a/app/Controller/Listing.php b/app/Controller/Listing.php
index b9c851f5..c784dd50 100644
--- a/app/Controller/Listing.php
+++ b/app/Controller/Listing.php
@@ -30,8 +30,11 @@ class Listing extends Base
->setQuery($query)
->calculate();
- $this->response->html($this->template->layout('listing/show', $params + array(
+ $this->response->html($this->helper->layout->app('listing/show', $params + array(
'paginator' => $paginator,
+ 'categories_list' => $this->category->getList($params['project']['id'], false),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($params['project']['id'], false),
+ 'custom_filters_list' => $this->customFilter->getAll($params['project']['id'], $this->userSession->getId()),
)));
}
}
diff --git a/app/Controller/Oauth.php b/app/Controller/Oauth.php
index 8c701cf7..452faecd 100644
--- a/app/Controller/Oauth.php
+++ b/app/Controller/Oauth.php
@@ -11,49 +11,19 @@ namespace Kanboard\Controller;
class Oauth extends Base
{
/**
- * Link or authenticate a Google account
- *
- * @access public
- */
- public function google()
- {
- $this->step1('google');
- }
-
- /**
- * Link or authenticate a Github account
- *
- * @access public
- */
- public function github()
- {
- $this->step1('github');
- }
-
- /**
- * Link or authenticate a Gitlab account
- *
- * @access public
- */
- public function gitlab()
- {
- $this->step1('gitlab');
- }
-
- /**
* Unlink external account
*
* @access public
*/
- public function unlink($backend = '')
+ public function unlink()
{
- $backend = $this->request->getStringParam('backend', $backend);
+ $backend = $this->request->getStringParam('backend');
$this->checkCSRFParam();
- if ($this->authentication->backend($backend)->unlink($this->userSession->getId())) {
- $this->session->flash(t('Your external account is not linked anymore to your profile.'));
+ if ($this->authenticationManager->getProvider($backend)->unlink($this->userSession->getId())) {
+ $this->flash->success(t('Your external account is not linked anymore to your profile.'));
} else {
- $this->session->flashError(t('Unable to unlink your external account.'));
+ $this->flash->failure(t('Unable to unlink your external account.'));
}
$this->response->redirect($this->helper->url->to('user', 'external', array('user_id' => $this->userSession->getId())));
@@ -63,46 +33,52 @@ class Oauth extends Base
* Redirect to the provider if no code received
*
* @access private
+ * @param string $provider
*/
- private function step1($backend)
+ protected function step1($provider)
{
$code = $this->request->getStringParam('code');
if (! empty($code)) {
- $this->step2($backend, $code);
+ $this->step2($provider, $code);
} else {
- $this->response->redirect($this->authentication->backend($backend)->getService()->getAuthorizationUrl());
+ $this->response->redirect($this->authenticationManager->getProvider($provider)->getService()->getAuthorizationUrl());
}
}
/**
* Link or authenticate the user
*
- * @access private
+ * @access protected
+ * @param string $provider
+ * @param string $code
*/
- private function step2($backend, $code)
+ protected function step2($provider, $code)
{
- $profile = $this->authentication->backend($backend)->getProfile($code);
+ $this->authenticationManager->getProvider($provider)->setCode($code);
if ($this->userSession->isLogged()) {
- $this->link($backend, $profile);
+ $this->link($provider);
}
- $this->authenticate($backend, $profile);
+ $this->authenticate($provider);
}
/**
* Link the account
*
- * @access private
+ * @access protected
+ * @param string $provider
*/
- private function link($backend, $profile)
+ protected function link($provider)
{
- if (empty($profile)) {
- $this->session->flashError(t('External authentication failed'));
+ $authProvider = $this->authenticationManager->getProvider($provider);
+
+ if (! $authProvider->authenticate()) {
+ $this->flash->failure(t('External authentication failed'));
} else {
- $this->session->flash(t('Your external account is linked to your profile successfully.'));
- $this->authentication->backend($backend)->updateUser($this->userSession->getId(), $profile);
+ $this->userProfile->assign($this->userSession->getId(), $authProvider->getUser());
+ $this->flash->success(t('Your external account is linked to your profile successfully.'));
}
$this->response->redirect($this->helper->url->to('user', 'external', array('user_id' => $this->userSession->getId())));
@@ -111,14 +87,15 @@ class Oauth extends Base
/**
* Authenticate the account
*
- * @access private
+ * @access protected
+ * @param string $provider
*/
- private function authenticate($backend, $profile)
+ protected function authenticate($provider)
{
- if (! empty($profile) && $this->authentication->backend($backend)->authenticate($profile['id'])) {
+ if ($this->authenticationManager->oauthAuthentication($provider)) {
$this->response->redirect($this->helper->url->to('app', 'index'));
} else {
- $this->response->html($this->template->layout('auth/index', array(
+ $this->response->html($this->helper->layout->app('auth/index', array(
'errors' => array('login' => t('External authentication failed')),
'values' => array(),
'no_layout' => true,
diff --git a/app/Controller/PasswordReset.php b/app/Controller/PasswordReset.php
new file mode 100644
index 00000000..f6a0eb8e
--- /dev/null
+++ b/app/Controller/PasswordReset.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Password Reset Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class PasswordReset extends Base
+{
+ /**
+ * Show the form to reset the password
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $this->checkActivation();
+
+ $this->response->html($this->helper->layout->app('password_reset/create', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'no_layout' => true,
+ )));
+ }
+
+ /**
+ * Validate and send the email
+ */
+ public function save()
+ {
+ $this->checkActivation();
+
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->passwordResetValidator->validateCreation($values);
+
+ if ($valid) {
+ $this->sendEmail($values['username']);
+ $this->response->redirect($this->helper->url->to('auth', 'login'));
+ }
+
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Show the form to set a new password
+ */
+ public function change(array $values = array(), array $errors = array())
+ {
+ $this->checkActivation();
+
+ $token = $this->request->getStringParam('token');
+ $user_id = $this->passwordReset->getUserIdByToken($token);
+
+ if ($user_id !== false) {
+ $this->response->html($this->helper->layout->app('password_reset/change', array(
+ 'token' => $token,
+ 'errors' => $errors,
+ 'values' => $values,
+ 'no_layout' => true,
+ )));
+ }
+
+ $this->response->redirect($this->helper->url->to('auth', 'login'));
+ }
+
+ /**
+ * Set the new password
+ */
+ public function update()
+ {
+ $this->checkActivation();
+
+ $token = $this->request->getStringParam('token');
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->passwordResetValidator->validateModification($values);
+
+ if ($valid) {
+ $user_id = $this->passwordReset->getUserIdByToken($token);
+
+ if ($user_id !== false) {
+ $this->user->update(array('id' => $user_id, 'password' => $values['password']));
+ $this->passwordReset->disable($user_id);
+ }
+
+ $this->response->redirect($this->helper->url->to('auth', 'login'));
+ }
+
+ $this->change($values, $errors);
+ }
+
+ /**
+ * Send the email
+ */
+ private function sendEmail($username)
+ {
+ $token = $this->passwordReset->create($username);
+
+ if ($token !== false) {
+ $user = $this->user->getByUsername($username);
+
+ $this->emailClient->send(
+ $user['email'],
+ $user['name'] ?: $user['username'],
+ t('Password Reset for Kanboard'),
+ $this->template->render('password_reset/email', array('token' => $token))
+ );
+ }
+ }
+
+ /**
+ * Check feature availability
+ */
+ private function checkActivation()
+ {
+ if ($this->config->get('password_reset', 0) == 0) {
+ $this->response->redirect($this->helper->url->to('auth', 'login'));
+ }
+ }
+}
diff --git a/app/Controller/Project.php b/app/Controller/Project.php
index f30d70e2..cdfbd94a 100644
--- a/app/Controller/Project.php
+++ b/app/Controller/Project.php
@@ -20,7 +20,7 @@ class Project extends Base
if ($this->userSession->isAdmin()) {
$project_ids = $this->project->getAllIds();
} else {
- $project_ids = $this->projectPermission->getMemberProjectIds($this->userSession->getId());
+ $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId());
}
$nb_projects = count($project_ids);
@@ -29,11 +29,10 @@ class Project extends Base
->setUrl('project', 'index')
->setMax(20)
->setOrder('name')
- ->setQuery($this->project->getQueryProjectDetails($project_ids))
+ ->setQuery($this->project->getQueryColumnStats($project_ids))
->calculate();
- $this->response->html($this->template->layout('project/index', array(
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
+ $this->response->html($this->helper->layout->app('project/index', array(
'paginator' => $paginator,
'nb_projects' => $nb_projects,
'title' => t('Projects').' ('.$nb_projects.')'
@@ -49,7 +48,7 @@ class Project extends Base
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('project/show', array(
+ $this->response->html($this->helper->layout->project('project/show', array(
'project' => $project,
'stats' => $this->project->getTaskStats($project['id']),
'title' => $project['name'],
@@ -70,15 +69,15 @@ class Project extends Base
$this->checkCSRFParam();
if ($this->project->{$switch.'PublicAccess'}($project['id'])) {
- $this->session->flash(t('Project updated successfully.'));
+ $this->flash->success(t('Project updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update this project.'));
+ $this->flash->failure(t('Unable to update this project.'));
}
$this->response->redirect($this->helper->url->to('project', 'share', array('project_id' => $project['id'])));
}
- $this->response->html($this->projectLayout('project/share', array(
+ $this->response->html($this->helper->layout->project('project/share', array(
'project' => $project,
'title' => t('Public access'),
)));
@@ -95,11 +94,11 @@ class Project extends Base
if ($this->request->isPost()) {
$this->projectMetadata->save($project['id'], $this->request->getValues());
- $this->session->flash(t('Project updated successfully.'));
+ $this->flash->success(t('Project updated successfully.'));
$this->response->redirect($this->helper->url->to('project', 'integrations', array('project_id' => $project['id'])));
}
- $this->response->html($this->projectLayout('project/integrations', array(
+ $this->response->html($this->helper->layout->project('project/integrations', array(
'project' => $project,
'title' => t('Integrations'),
'webhook_token' => $this->config->get('webhook_token'),
@@ -120,11 +119,11 @@ class Project extends Base
if ($this->request->isPost()) {
$values = $this->request->getValues();
$this->projectNotification->saveSettings($project['id'], $values);
- $this->session->flash(t('Project updated successfully.'));
+ $this->flash->success(t('Project updated successfully.'));
$this->response->redirect($this->helper->url->to('project', 'notifications', array('project_id' => $project['id'])));
}
- $this->response->html($this->projectLayout('project/notifications', array(
+ $this->response->html($this->helper->layout->project('project/notifications', array(
'notifications' => $this->projectNotification->readSettings($project['id']),
'types' => $this->projectNotificationType->getTypes(),
'project' => $project,
@@ -133,171 +132,6 @@ class Project extends Base
}
/**
- * Display a form to edit a project
- *
- * @access public
- */
- public function edit(array $values = array(), array $errors = array())
- {
- $project = $this->getProject();
-
- $this->response->html($this->projectLayout('project/edit', array(
- 'values' => empty($values) ? $project : $values,
- 'errors' => $errors,
- 'project' => $project,
- 'title' => t('Edit project')
- )));
- }
-
- /**
- * Validate and update a project
- *
- * @access public
- */
- public function update()
- {
- $project = $this->getProject();
- $values = $this->request->getValues();
-
- if (isset($values['is_private'])) {
- if (! $this->helper->user->isProjectAdministrationAllowed($project['id'])) {
- unset($values['is_private']);
- }
- } elseif ($project['is_private'] == 1 && ! isset($values['is_private'])) {
- if ($this->helper->user->isProjectAdministrationAllowed($project['id'])) {
- $values += array('is_private' => 0);
- }
- }
-
- list($valid, $errors) = $this->project->validateModification($values);
-
- if ($valid) {
- if ($this->project->update($values)) {
- $this->session->flash(t('Project updated successfully.'));
- $this->response->redirect($this->helper->url->to('project', 'edit', array('project_id' => $project['id'])));
- } else {
- $this->session->flashError(t('Unable to update this project.'));
- }
- }
-
- $this->edit($values, $errors);
- }
-
- /**
- * Users list for the selected project
- *
- * @access public
- */
- public function users()
- {
- $project = $this->getProject();
-
- $this->response->html($this->projectLayout('project/users', array(
- 'project' => $project,
- 'users' => $this->projectPermission->getAllUsers($project['id']),
- 'title' => t('Edit project access list')
- )));
- }
-
- /**
- * Allow everybody
- *
- * @access public
- */
- public function allowEverybody()
- {
- $project = $this->getProject();
- $values = $this->request->getValues() + array('is_everybody_allowed' => 0);
- list($valid, ) = $this->projectPermission->validateProjectModification($values);
-
- if ($valid) {
- if ($this->project->update($values)) {
- $this->session->flash(t('Project updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update this project.'));
- }
- }
-
- $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $project['id'])));
- }
-
- /**
- * Allow a specific user (admin only)
- *
- * @access public
- */
- public function allow()
- {
- $values = $this->request->getValues();
- list($valid, ) = $this->projectPermission->validateUserModification($values);
-
- if ($valid) {
- if ($this->projectPermission->addMember($values['project_id'], $values['user_id'])) {
- $this->session->flash(t('Project updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update this project.'));
- }
- }
-
- $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $values['project_id'])));
- }
-
- /**
- * Change the role of a project member
- *
- * @access public
- */
- public function role()
- {
- $this->checkCSRFParam();
-
- $values = array(
- 'project_id' => $this->request->getIntegerParam('project_id'),
- 'user_id' => $this->request->getIntegerParam('user_id'),
- 'is_owner' => $this->request->getIntegerParam('is_owner'),
- );
-
- list($valid, ) = $this->projectPermission->validateUserModification($values);
-
- if ($valid) {
- if ($this->projectPermission->changeRole($values['project_id'], $values['user_id'], $values['is_owner'])) {
- $this->session->flash(t('Project updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update this project.'));
- }
- }
-
- $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $values['project_id'])));
- }
-
- /**
- * Revoke user access (admin only)
- *
- * @access public
- */
- public function revoke()
- {
- $this->checkCSRFParam();
-
- $values = array(
- 'project_id' => $this->request->getIntegerParam('project_id'),
- 'user_id' => $this->request->getIntegerParam('user_id'),
- );
-
- list($valid, ) = $this->projectPermission->validateUserModification($values);
-
- if ($valid) {
- if ($this->projectPermission->revokeMember($values['project_id'], $values['user_id'])) {
- $this->session->flash(t('Project updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update this project.'));
- }
- }
-
- $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $values['project_id'])));
- }
-
- /**
* Remove a project
*
* @access public
@@ -310,15 +144,15 @@ class Project extends Base
$this->checkCSRFParam();
if ($this->project->remove($project['id'])) {
- $this->session->flash(t('Project removed successfully.'));
+ $this->flash->success(t('Project removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this project.'));
+ $this->flash->failure(t('Unable to remove this project.'));
}
$this->response->redirect($this->helper->url->to('project', 'index'));
}
- $this->response->html($this->projectLayout('project/remove', array(
+ $this->response->html($this->helper->layout->project('project/remove', array(
'project' => $project,
'title' => t('Remove project')
)));
@@ -336,17 +170,18 @@ class Project extends Base
$project = $this->getProject();
if ($this->request->getStringParam('duplicate') === 'yes') {
- $values = array_keys($this->request->getValues());
- if ($this->projectDuplication->duplicate($project['id'], $values) !== false) {
- $this->session->flash(t('Project cloned successfully.'));
+ $project_id = $this->projectDuplication->duplicate($project['id'], array_keys($this->request->getValues()), $this->userSession->getId());
+
+ if ($project_id !== false) {
+ $this->flash->success(t('Project cloned successfully.'));
} else {
- $this->session->flashError(t('Unable to clone this project.'));
+ $this->flash->failure(t('Unable to clone this project.'));
}
- $this->response->redirect($this->helper->url->to('project', 'index'));
+ $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id)));
}
- $this->response->html($this->projectLayout('project/duplicate', array(
+ $this->response->html($this->helper->layout->project('project/duplicate', array(
'project' => $project,
'title' => t('Clone this project')
)));
@@ -365,15 +200,15 @@ class Project extends Base
$this->checkCSRFParam();
if ($this->project->disable($project['id'])) {
- $this->session->flash(t('Project disabled successfully.'));
+ $this->flash->success(t('Project disabled successfully.'));
} else {
- $this->session->flashError(t('Unable to disable this project.'));
+ $this->flash->failure(t('Unable to disable this project.'));
}
$this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project['id'])));
}
- $this->response->html($this->projectLayout('project/disable', array(
+ $this->response->html($this->helper->layout->project('project/disable', array(
'project' => $project,
'title' => t('Project activation')
)));
@@ -392,59 +227,17 @@ class Project extends Base
$this->checkCSRFParam();
if ($this->project->enable($project['id'])) {
- $this->session->flash(t('Project activated successfully.'));
+ $this->flash->success(t('Project activated successfully.'));
} else {
- $this->session->flashError(t('Unable to activate this project.'));
+ $this->flash->failure(t('Unable to activate this project.'));
}
$this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project['id'])));
}
- $this->response->html($this->projectLayout('project/enable', array(
+ $this->response->html($this->helper->layout->project('project/enable', array(
'project' => $project,
'title' => t('Project activation')
)));
}
-
- /**
- * Display a form to create a new project
- *
- * @access public
- */
- public function create(array $values = array(), array $errors = array())
- {
- $is_private = $this->request->getIntegerParam('private', $this->userSession->isAdmin() || $this->userSession->isProjectAdmin() ? 0 : 1);
-
- $this->response->html($this->template->layout('project/new', array(
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
- 'values' => empty($values) ? array('is_private' => $is_private) : $values,
- 'errors' => $errors,
- 'is_private' => $is_private,
- 'title' => $is_private ? t('New private project') : t('New project'),
- )));
- }
-
- /**
- * Validate and save a new project
- *
- * @access public
- */
- public function save()
- {
- $values = $this->request->getValues();
- list($valid, $errors) = $this->project->validateCreation($values);
-
- if ($valid) {
- $project_id = $this->project->create($values, $this->userSession->getId(), true);
-
- if ($project_id > 0) {
- $this->session->flash(t('Your project have been created successfully.'));
- $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id)));
- }
-
- $this->session->flashError(t('Unable to create your project.'));
- }
-
- $this->create($values, $errors);
- }
}
diff --git a/app/Controller/ProjectCreation.php b/app/Controller/ProjectCreation.php
new file mode 100644
index 00000000..88f41fcd
--- /dev/null
+++ b/app/Controller/ProjectCreation.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Project Creation Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class ProjectCreation extends Base
+{
+ /**
+ * Display a form to create a new project
+ *
+ * @access public
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $is_private = isset($values['is_private']) && $values['is_private'] == 1;
+ $projects_list = array(0 => t('Do not duplicate anything')) + $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId());
+
+ $this->response->html($this->helper->layout->app('project_creation/create', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'is_private' => $is_private,
+ 'projects_list' => $projects_list,
+ 'title' => $is_private ? t('New private project') : t('New project'),
+ )));
+ }
+
+ /**
+ * Display a form to create a private project
+ *
+ * @access public
+ */
+ public function createPrivate(array $values = array(), array $errors = array())
+ {
+ $values['is_private'] = 1;
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Validate and save a new project
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->projectValidator->validateCreation($values);
+
+ if ($valid) {
+ $project_id = $this->createOrDuplicate($values);
+
+ if ($project_id > 0) {
+ $this->flash->success(t('Your project have been created successfully.'));
+ return $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id)));
+ }
+
+ $this->flash->failure(t('Unable to create your project.'));
+ }
+
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Create or duplicate a project
+ *
+ * @access private
+ * @param array $values
+ * @return boolean|integer
+ */
+ private function createOrDuplicate(array $values)
+ {
+ if (empty($values['src_project_id'])) {
+ return $this->createNewProject($values);
+ }
+
+ return $this->duplicateNewProject($values);
+ }
+
+ /**
+ * Save a new project
+ *
+ * @access private
+ * @param array $values
+ * @return boolean|integer
+ */
+ private function createNewProject(array $values)
+ {
+ $project = array(
+ 'name' => $values['name'],
+ 'is_private' => $values['is_private'],
+ );
+
+ return $this->project->create($project, $this->userSession->getId(), true);
+ }
+
+ /**
+ * Creatte from another project
+ *
+ * @access private
+ * @param array $values
+ * @return boolean|integer
+ */
+ private function duplicateNewProject(array $values)
+ {
+ $selection = array();
+
+ foreach ($this->projectDuplication->getOptionalSelection() as $item) {
+ if (isset($values[$item]) && $values[$item] == 1) {
+ $selection[] = $item;
+ }
+ }
+
+ return $this->projectDuplication->duplicate(
+ $values['src_project_id'],
+ $selection,
+ $this->userSession->getId(),
+ $values['name'],
+ $values['is_private'] == 1
+ );
+ }
+}
diff --git a/app/Controller/ProjectEdit.php b/app/Controller/ProjectEdit.php
new file mode 100644
index 00000000..f4a3a7cb
--- /dev/null
+++ b/app/Controller/ProjectEdit.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Project Edit Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class ProjectEdit extends Base
+{
+ /**
+ * General edition (most common operations)
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $this->renderView('project_edit/general', $values, $errors);
+ }
+
+ /**
+ * Change start and end dates
+ *
+ * @access public
+ */
+ public function dates(array $values = array(), array $errors = array())
+ {
+ $this->renderView('project_edit/dates', $values, $errors);
+ }
+
+ /**
+ * Change project description
+ *
+ * @access public
+ */
+ public function description(array $values = array(), array $errors = array())
+ {
+ $this->renderView('project_edit/description', $values, $errors);
+ }
+
+ /**
+ * Change task priority
+ *
+ * @access public
+ */
+ public function priority(array $values = array(), array $errors = array())
+ {
+ $this->renderView('project_edit/task_priority', $values, $errors);
+ }
+
+ /**
+ * Validate and update a project
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+ $redirect = $this->request->getStringParam('redirect', 'edit');
+
+ $values = $this->prepareValues($redirect, $project, $values);
+ list($valid, $errors) = $this->projectValidator->validateModification($values);
+
+ if ($valid) {
+ if ($this->project->update($values)) {
+ $this->flash->success(t('Project updated successfully.'));
+ $this->response->redirect($this->helper->url->to('ProjectEdit', $redirect, array('project_id' => $project['id'])));
+ } else {
+ $this->flash->failure(t('Unable to update this project.'));
+ }
+ }
+
+ $this->$redirect($values, $errors);
+ }
+
+ /**
+ * Prepare form values
+ *
+ * @access private
+ * @param string $redirect
+ * @param array $project
+ * @param array $values
+ * @return array
+ */
+ private function prepareValues($redirect, array $project, array $values)
+ {
+ if ($redirect === 'edit') {
+ if (isset($values['is_private'])) {
+ if (! $this->helper->user->hasProjectAccess('ProjectCreation', 'create', $project['id'])) {
+ unset($values['is_private']);
+ }
+ } elseif ($project['is_private'] == 1 && ! isset($values['is_private'])) {
+ if ($this->helper->user->hasProjectAccess('ProjectCreation', 'create', $project['id'])) {
+ $values += array('is_private' => 0);
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Common metthod to render different views
+ *
+ * @access private
+ * @param string $template
+ * @param array $values
+ * @param array $errors
+ */
+ private function renderView($template, array $values, array $errors)
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->helper->layout->project($template, array(
+ 'owners' => $this->projectUserRole->getAssignableUsersList($project['id'], true),
+ 'values' => empty($values) ? $project : $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'title' => t('Edit project')
+ )));
+ }
+}
diff --git a/app/Controller/ProjectFile.php b/app/Controller/ProjectFile.php
new file mode 100644
index 00000000..96764a92
--- /dev/null
+++ b/app/Controller/ProjectFile.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Project File Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class ProjectFile extends Base
+{
+ /**
+ * File upload form
+ *
+ * @access public
+ */
+ public function create()
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->template->render('project_file/create', array(
+ 'project' => $project,
+ 'max_size' => $this->helper->text->phpToBytes(ini_get('upload_max_filesize')),
+ )));
+ }
+
+ /**
+ * Save uploaded files
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $project = $this->getProject();
+
+ if (! $this->projectFile->uploadFiles($project['id'], $this->request->getFileInfo('files'))) {
+ $this->flash->failure(t('Unable to upload the file.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectOverview', 'show', array('project_id' => $project['id'])), true);
+ }
+
+ /**
+ * Remove a file
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $project = $this->getProject();
+ $file = $this->projectFile->getById($this->request->getIntegerParam('file_id'));
+
+ if ($this->projectFile->remove($file['id'])) {
+ $this->flash->success(t('File removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this file.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectOverview', 'show', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Confirmation dialog before removing a file
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $project = $this->getProject();
+ $file = $this->projectFile->getById($this->request->getIntegerParam('file_id'));
+
+ $this->response->html($this->template->render('project_file/remove', array(
+ 'project' => $project,
+ 'file' => $file,
+ )));
+ }
+}
diff --git a/app/Controller/ProjectOverview.php b/app/Controller/ProjectOverview.php
new file mode 100644
index 00000000..b0687ed3
--- /dev/null
+++ b/app/Controller/ProjectOverview.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Project Overview Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class ProjectOverview extends Base
+{
+ /**
+ * Show project overview
+ */
+ public function show()
+ {
+ $params = $this->getProjectFilters('ProjectOverview', 'show');
+ $params['users'] = $this->projectUserRole->getAllUsersGroupedByRole($params['project']['id']);
+ $params['roles'] = $this->role->getProjectRoles();
+ $params['events'] = $this->projectActivity->getProject($params['project']['id'], 10);
+ $params['images'] = $this->projectFile->getAllImages($params['project']['id']);
+ $params['files'] = $this->projectFile->getAllDocuments($params['project']['id']);
+
+ $this->project->getColumnStats($params['project']);
+
+ $this->response->html($this->helper->layout->app('project_overview/show', $params));
+ }
+}
diff --git a/app/Controller/ProjectPermission.php b/app/Controller/ProjectPermission.php
new file mode 100644
index 00000000..800da02f
--- /dev/null
+++ b/app/Controller/ProjectPermission.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Kanboard\Core\Security\Role;
+
+/**
+ * Project Permission
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class ProjectPermission extends Base
+{
+ /**
+ * Permissions are only available for team projects
+ *
+ * @access protected
+ * @param integer $project_id Default project id
+ * @return array
+ */
+ protected function getProject($project_id = 0)
+ {
+ $project = parent::getProject($project_id);
+
+ if ($project['is_private'] == 1) {
+ $this->forbidden();
+ }
+
+ return $project;
+ }
+
+ /**
+ * Show all permissions
+ *
+ * @access public
+ */
+ public function index(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ if (empty($values)) {
+ $values['role'] = Role::PROJECT_MEMBER;
+ }
+
+ $this->response->html($this->helper->layout->project('project_permission/index', array(
+ 'project' => $project,
+ 'users' => $this->projectUserRole->getUsers($project['id']),
+ 'groups' => $this->projectGroupRole->getGroups($project['id']),
+ 'roles' => $this->role->getProjectRoles(),
+ 'values' => $values,
+ 'errors' => $errors,
+ 'title' => t('Project Permissions'),
+ )));
+ }
+
+ /**
+ * Allow everybody
+ *
+ * @access public
+ */
+ public function allowEverybody()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues() + array('is_everybody_allowed' => 0);
+
+ if ($this->project->update($values)) {
+ $this->flash->success(t('Project updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this project.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Add user to the project
+ *
+ * @access public
+ */
+ public function addUser()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+
+ if ($this->projectUserRole->addUser($values['project_id'], $values['user_id'], $values['role'])) {
+ $this->flash->success(t('Project updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this project.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Revoke user access
+ *
+ * @access public
+ */
+ public function removeUser()
+ {
+ $this->checkCSRFParam();
+ $project = $this->getProject();
+ $user_id = $this->request->getIntegerParam('user_id');
+
+ if ($this->projectUserRole->removeUser($project['id'], $user_id)) {
+ $this->flash->success(t('Project updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this project.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Change user role
+ *
+ * @access public
+ */
+ public function changeUserRole()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getJson();
+
+ if (! empty($project) && ! empty($values) && $this->projectUserRole->changeUserRole($project['id'], $values['id'], $values['role'])) {
+ $this->response->json(array('status' => 'ok'));
+ } else {
+ $this->response->json(array('status' => 'error'));
+ }
+ }
+
+ /**
+ * Add group to the project
+ *
+ * @access public
+ */
+ public function addGroup()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+
+ if (empty($values['group_id']) && ! empty($values['external_id'])) {
+ $values['group_id'] = $this->group->create($values['name'], $values['external_id']);
+ }
+
+ if ($this->projectGroupRole->addGroup($project['id'], $values['group_id'], $values['role'])) {
+ $this->flash->success(t('Project updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this project.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Revoke group access
+ *
+ * @access public
+ */
+ public function removeGroup()
+ {
+ $this->checkCSRFParam();
+ $project = $this->getProject();
+ $group_id = $this->request->getIntegerParam('group_id');
+
+ if ($this->projectGroupRole->removeGroup($project['id'], $group_id)) {
+ $this->flash->success(t('Project updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this project.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Change group role
+ *
+ * @access public
+ */
+ public function changeGroupRole()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getJson();
+
+ if (! empty($project) && ! empty($values) && $this->projectGroupRole->changeGroupRole($project['id'], $values['id'], $values['role'])) {
+ $this->response->json(array('status' => 'ok'));
+ } else {
+ $this->response->json(array('status' => 'error'));
+ }
+ }
+}
diff --git a/app/Controller/Projectuser.php b/app/Controller/Projectuser.php
index 18829b3c..a6d4fe4e 100644
--- a/app/Controller/Projectuser.php
+++ b/app/Controller/Projectuser.php
@@ -4,6 +4,7 @@ namespace Kanboard\Controller;
use Kanboard\Model\User as UserModel;
use Kanboard\Model\Task as TaskModel;
+use Kanboard\Core\Security\Role;
/**
* Project User overview
@@ -13,23 +14,6 @@ use Kanboard\Model\Task as TaskModel;
*/
class Projectuser extends Base
{
- /**
- * Common layout for users overview views
- *
- * @access private
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- private function layout($template, array $params)
- {
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
- $params['content_for_sublayout'] = $this->template->render($template, $params);
- $params['filter'] = array('user_id' => $params['user_id']);
-
- return $this->template->layout('project_user/layout', $params);
- }
-
private function common()
{
$user_id = $this->request->getIntegerParam('user_id', UserModel::EVERYBODY_ID);
@@ -37,19 +21,19 @@ class Projectuser extends Base
if ($this->userSession->isAdmin()) {
$project_ids = $this->project->getAllIds();
} else {
- $project_ids = $this->projectPermission->getMemberProjectIds($this->userSession->getId());
+ $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId());
}
- return array($user_id, $project_ids, $this->user->getList(true));
+ return array($user_id, $project_ids, $this->user->getActiveUsersList(true));
}
- private function role($is_owner, $action, $title, $title_user)
+ private function role($role, $action, $title, $title_user)
{
list($user_id, $project_ids, $users) = $this->common();
- $query = $this->projectPermission->getQueryByRole($project_ids, $is_owner)->callback(array($this->project, 'applyColumnStats'));
+ $query = $this->projectPermission->getQueryByRole($project_ids, $role)->callback(array($this->project, 'applyColumnStats'));
- if ($user_id !== UserModel::EVERYBODY_ID) {
+ if ($user_id !== UserModel::EVERYBODY_ID && isset($users[$user_id])) {
$query->eq(UserModel::TABLE.'.id', $user_id);
$title = t($title_user, $users[$user_id]);
}
@@ -61,7 +45,7 @@ class Projectuser extends Base
->setQuery($query)
->calculate();
- $this->response->html($this->layout('project_user/roles', array(
+ $this->response->html($this->helper->layout->projectUser('project_user/roles', array(
'paginator' => $paginator,
'title' => $title,
'user_id' => $user_id,
@@ -75,7 +59,7 @@ class Projectuser extends Base
$query = $this->taskFinder->getProjectUserOverviewQuery($project_ids, $is_active);
- if ($user_id !== UserModel::EVERYBODY_ID) {
+ if ($user_id !== UserModel::EVERYBODY_ID && isset($users[$user_id])) {
$query->eq(TaskModel::TABLE.'.owner_id', $user_id);
$title = t($title_user, $users[$user_id]);
}
@@ -87,7 +71,7 @@ class Projectuser extends Base
->setQuery($query)
->calculate();
- $this->response->html($this->layout('project_user/tasks', array(
+ $this->response->html($this->helper->layout->projectUser('project_user/tasks', array(
'paginator' => $paginator,
'title' => $title,
'user_id' => $user_id,
@@ -101,7 +85,7 @@ class Projectuser extends Base
*/
public function managers()
{
- $this->role(1, 'managers', t('People who are project managers'), 'Projects where "%s" is manager');
+ $this->role(Role::PROJECT_MANAGER, 'managers', t('People who are project managers'), 'Projects where "%s" is manager');
}
/**
@@ -110,7 +94,7 @@ class Projectuser extends Base
*/
public function members()
{
- $this->role(0, 'members', t('People who are project members'), 'Projects where "%s" is member');
+ $this->role(ROLE::PROJECT_MEMBER, 'members', t('People who are project members'), 'Projects where "%s" is member');
}
/**
@@ -130,4 +114,17 @@ class Projectuser extends Base
{
$this->tasks(TaskModel::STATUS_CLOSED, 'closed', t('Closed tasks'), 'Closed tasks assigned to "%s"');
}
+
+ /**
+ * Users tooltip
+ */
+ public function users()
+ {
+ $project = $this->getProject();
+
+ return $this->response->html($this->template->render('project_user/tooltip_users', array(
+ 'users' => $this->projectUserRole->getAllUsersGroupedByRole($project['id']),
+ 'roles' => $this->role->getProjectRoles(),
+ )));
+ }
}
diff --git a/app/Controller/Search.php b/app/Controller/Search.php
index 0aff9073..9b9b9e65 100644
--- a/app/Controller/Search.php
+++ b/app/Controller/Search.php
@@ -12,7 +12,7 @@ class Search extends Base
{
public function index()
{
- $projects = $this->projectPermission->getAllowedProjects($this->userSession->getId());
+ $projects = $this->projectUserRole->getProjectsByUser($this->userSession->getId());
$search = urldecode($this->request->getStringParam('search'));
$nb_tasks = 0;
@@ -36,8 +36,7 @@ class Search extends Base
$nb_tasks = $paginator->getTotal();
}
- $this->response->html($this->template->layout('search/index', array(
- 'board_selector' => $projects,
+ $this->response->html($this->helper->layout->app('search/index', array(
'values' => array(
'search' => $search,
'controller' => 'search',
diff --git a/app/Controller/Subtask.php b/app/Controller/Subtask.php
index 4ef3e74e..8ca0ce92 100644
--- a/app/Controller/Subtask.php
+++ b/app/Controller/Subtask.php
@@ -2,8 +2,6 @@
namespace Kanboard\Controller;
-use Kanboard\Model\Subtask as SubtaskModel;
-
/**
* Subtask controller
*
@@ -13,20 +11,19 @@ use Kanboard\Model\Subtask as SubtaskModel;
class Subtask extends Base
{
/**
- * Get the current subtask
- *
- * @access private
- * @return array
+ * Show list of subtasks
*/
- private function getSubtask()
+ public function show()
{
- $subtask = $this->subtask->getById($this->request->getIntegerParam('subtask_id'));
-
- if (empty($subtask)) {
- $this->notfound();
- }
+ $task = $this->getTask();
- return $subtask;
+ $this->response->html($this->helper->layout->task('subtask/show', array(
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']),
+ 'task' => $task,
+ 'project' => $this->getProject(),
+ 'subtasks' => $this->subtask->getAll($task['id']),
+ 'editable' => true,
+ )));
}
/**
@@ -45,10 +42,10 @@ class Subtask extends Base
);
}
- $this->response->html($this->taskLayout('subtask/create', array(
+ $this->response->html($this->helper->layout->task('subtask/create', array(
'values' => $values,
'errors' => $errors,
- 'users_list' => $this->projectPermission->getMemberList($task['project_id']),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']),
'task' => $task,
)));
}
@@ -63,20 +60,20 @@ class Subtask extends Base
$task = $this->getTask();
$values = $this->request->getValues();
- list($valid, $errors) = $this->subtask->validateCreation($values);
+ list($valid, $errors) = $this->subtaskValidator->validateCreation($values);
if ($valid) {
if ($this->subtask->create($values)) {
- $this->session->flash(t('Sub-task added successfully.'));
+ $this->flash->success(t('Sub-task added successfully.'));
} else {
- $this->session->flashError(t('Unable to create your sub-task.'));
+ $this->flash->failure(t('Unable to create your sub-task.'));
}
if (isset($values['another_subtask']) && $values['another_subtask'] == 1) {
- $this->response->redirect($this->helper->url->to('subtask', 'create', array('project_id' => $task['project_id'], 'task_id' => $task['id'], 'another_subtask' => 1)));
+ return $this->create(array('project_id' => $task['project_id'], 'task_id' => $task['id'], 'another_subtask' => 1));
}
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']), 'subtasks'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']), 'subtasks'), true);
}
$this->create($values, $errors);
@@ -92,10 +89,10 @@ class Subtask extends Base
$task = $this->getTask();
$subtask = $this->getSubTask();
- $this->response->html($this->taskLayout('subtask/edit', array(
+ $this->response->html($this->helper->layout->task('subtask/edit', array(
'values' => empty($values) ? $subtask : $values,
'errors' => $errors,
- 'users_list' => $this->projectPermission->getMemberList($task['project_id']),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']),
'status_list' => $this->subtask->getStatusList(),
'subtask' => $subtask,
'task' => $task,
@@ -113,16 +110,16 @@ class Subtask extends Base
$this->getSubtask();
$values = $this->request->getValues();
- list($valid, $errors) = $this->subtask->validateModification($values);
+ list($valid, $errors) = $this->subtaskValidator->validateModification($values);
if ($valid) {
if ($this->subtask->update($values)) {
- $this->session->flash(t('Sub-task updated successfully.'));
+ $this->flash->success(t('Sub-task updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update your sub-task.'));
+ $this->flash->failure(t('Unable to update your sub-task.'));
}
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']), 'subtasks'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
}
$this->edit($values, $errors);
@@ -138,7 +135,7 @@ class Subtask extends Base
$task = $this->getTask();
$subtask = $this->getSubtask();
- $this->response->html($this->taskLayout('subtask/remove', array(
+ $this->response->html($this->helper->layout->task('subtask/remove', array(
'subtask' => $subtask,
'task' => $task,
)));
@@ -156,102 +153,12 @@ class Subtask extends Base
$subtask = $this->getSubtask();
if ($this->subtask->remove($subtask['id'])) {
- $this->session->flash(t('Sub-task removed successfully.'));
+ $this->flash->success(t('Sub-task removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this sub-task.'));
+ $this->flash->failure(t('Unable to remove this sub-task.'));
}
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']), 'subtasks'));
- }
-
- /**
- * Change status to the next status: Toto -> In Progress -> Done
- *
- * @access public
- */
- public function toggleStatus()
- {
- $task = $this->getTask();
- $subtask = $this->getSubtask();
- $redirect = $this->request->getStringParam('redirect', 'task');
-
- $this->subtask->toggleStatus($subtask['id']);
-
- if ($redirect === 'board') {
- $this->session['has_subtask_inprogress'] = $this->subtask->hasSubtaskInProgress($this->userSession->getId());
-
- $this->response->html($this->template->render('board/tooltip_subtasks', array(
- 'subtasks' => $this->subtask->getAll($task['id']),
- 'task' => $task,
- )));
- }
-
- $this->toggleRedirect($task, $redirect);
- }
-
- /**
- * Handle subtask restriction (popover)
- *
- * @access public
- */
- public function subtaskRestriction()
- {
- $task = $this->getTask();
- $subtask = $this->getSubtask();
-
- $this->response->html($this->template->render('subtask/restriction_change_status', array(
- 'status_list' => array(
- SubtaskModel::STATUS_TODO => t('Todo'),
- SubtaskModel::STATUS_DONE => t('Done'),
- ),
- 'subtask_inprogress' => $this->subtask->getSubtaskInProgress($this->userSession->getId()),
- 'subtask' => $subtask,
- 'task' => $task,
- 'redirect' => $this->request->getStringParam('redirect'),
- )));
- }
-
- /**
- * Change status of the in progress subtask and the other subtask
- *
- * @access public
- */
- public function changeRestrictionStatus()
- {
- $task = $this->getTask();
- $subtask = $this->getSubtask();
- $values = $this->request->getValues();
-
- // Change status of the previous in progress subtask
- $this->subtask->update(array(
- 'id' => $values['id'],
- 'status' => $values['status'],
- ));
-
- // Set the current subtask to in pogress
- $this->subtask->update(array(
- 'id' => $subtask['id'],
- 'status' => SubtaskModel::STATUS_INPROGRESS,
- ));
-
- $this->toggleRedirect($task, $values['redirect']);
- }
-
- /**
- * Redirect to the right page
- *
- * @access private
- */
- private function toggleRedirect(array $task, $redirect)
- {
- switch ($redirect) {
- case 'board':
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- case 'dashboard':
- $this->response->redirect($this->helper->url->to('app', 'index'));
- default:
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'subtasks'));
- }
+ $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
}
/**
@@ -261,14 +168,15 @@ class Subtask extends Base
*/
public function movePosition()
{
- $this->checkCSRFParam();
$project_id = $this->request->getIntegerParam('project_id');
$task_id = $this->request->getIntegerParam('task_id');
- $subtask_id = $this->request->getIntegerParam('subtask_id');
- $direction = $this->request->getStringParam('direction');
- $method = $direction === 'up' ? 'moveUp' : 'moveDown';
+ $values = $this->request->getJson();
+
+ if (! empty($values) && $this->helper->user->hasProjectAccess('Subtask', 'movePosition', $project_id)) {
+ $result = $this->subtask->changePosition($task_id, $values['subtask_id'], $values['position']);
+ return $this->response->json(array('result' => $result));
+ }
- $this->subtask->$method($task_id, $subtask_id);
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $project_id, 'task_id' => $task_id), 'subtasks'));
+ $this->forbidden();
}
}
diff --git a/app/Controller/SubtaskRestriction.php b/app/Controller/SubtaskRestriction.php
new file mode 100644
index 00000000..56024867
--- /dev/null
+++ b/app/Controller/SubtaskRestriction.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Kanboard\Model\Subtask as SubtaskModel;
+
+/**
+ * Subtask Restriction
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class SubtaskRestriction extends Base
+{
+ /**
+ * Show popup
+ *
+ * @access public
+ */
+ public function popover()
+ {
+ $task = $this->getTask();
+ $subtask = $this->getSubtask();
+
+ $this->response->html($this->template->render('subtask_restriction/popover', array(
+ 'status_list' => array(
+ SubtaskModel::STATUS_TODO => t('Todo'),
+ SubtaskModel::STATUS_DONE => t('Done'),
+ ),
+ 'subtask_inprogress' => $this->subtask->getSubtaskInProgress($this->userSession->getId()),
+ 'subtask' => $subtask,
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Change status of the in progress subtask and the other subtask
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $task = $this->getTask();
+ $subtask = $this->getSubtask();
+ $values = $this->request->getValues();
+
+ // Change status of the previous "in progress" subtask
+ $this->subtask->update(array(
+ 'id' => $values['id'],
+ 'status' => $values['status'],
+ ));
+
+ // Set the current subtask to "in progress"
+ $this->subtask->update(array(
+ 'id' => $subtask['id'],
+ 'status' => SubtaskModel::STATUS_INPROGRESS,
+ ));
+
+ $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
+ }
+}
diff --git a/app/Controller/SubtaskStatus.php b/app/Controller/SubtaskStatus.php
new file mode 100644
index 00000000..4fb82fc0
--- /dev/null
+++ b/app/Controller/SubtaskStatus.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Subtask Status
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class SubtaskStatus extends Base
+{
+ /**
+ * Change status to the next status: Toto -> In Progress -> Done
+ *
+ * @access public
+ */
+ public function change()
+ {
+ $task = $this->getTask();
+ $subtask = $this->getSubtask();
+
+ $status = $this->subtask->toggleStatus($subtask['id']);
+
+ if ($this->request->getIntegerParam('refresh-table') === 0) {
+ $subtask['status'] = $status;
+ $html = $this->helper->subtask->toggleStatus($subtask, $task['project_id']);
+ } else {
+ $html = $this->renderTable($task);
+ }
+
+ $this->response->html($html);
+ }
+
+ /**
+ * Start/stop timer for subtasks
+ *
+ * @access public
+ */
+ public function timer()
+ {
+ $task = $this->getTask();
+ $subtask_id = $this->request->getIntegerParam('subtask_id');
+ $timer = $this->request->getStringParam('timer');
+
+ if ($timer === 'start') {
+ $this->subtaskTimeTracking->logStartTime($subtask_id, $this->userSession->getId());
+ } elseif ($timer === 'stop') {
+ $this->subtaskTimeTracking->logEndTime($subtask_id, $this->userSession->getId());
+ $this->subtaskTimeTracking->updateTaskTimeTracking($task['id']);
+ }
+
+ $this->response->html($this->renderTable($task));
+ }
+
+ /**
+ * Render table
+ *
+ * @access private
+ * @param array $task
+ * @return string
+ */
+ private function renderTable(array $task)
+ {
+ return $this->template->render('subtask/table', array(
+ 'task' => $task,
+ 'subtasks' => $this->subtask->getAll($task['id']),
+ 'editable' => true,
+ 'redirect' => 'task',
+ ));
+ }
+}
diff --git a/app/Controller/Swimlane.php b/app/Controller/Swimlane.php
index 0b29f598..8270a16f 100644
--- a/app/Controller/Swimlane.php
+++ b/app/Controller/Swimlane.php
@@ -24,7 +24,7 @@ class Swimlane extends Base
$swimlane = $this->swimlane->getById($this->request->getIntegerParam('swimlane_id'));
if (empty($swimlane)) {
- $this->session->flashError(t('Swimlane not found.'));
+ $this->flash->failure(t('Swimlane not found.'));
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project_id)));
}
@@ -36,18 +36,32 @@ class Swimlane extends Base
*
* @access public
*/
- public function index(array $values = array(), array $errors = array())
+ public function index()
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('swimlane/index', array(
+ $this->response->html($this->helper->layout->project('swimlane/index', array(
'default_swimlane' => $this->swimlane->getDefault($project['id']),
'active_swimlanes' => $this->swimlane->getAllByStatus($project['id'], SwimlaneModel::ACTIVE),
'inactive_swimlanes' => $this->swimlane->getAllByStatus($project['id'], SwimlaneModel::INACTIVE),
+ 'project' => $project,
+ 'title' => t('Swimlanes')
+ )));
+ }
+
+ /**
+ * Create a new swimlane
+ *
+ * @access public
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->template->render('swimlane/create', array(
'values' => $values + array('project_id' => $project['id']),
'errors' => $errors,
'project' => $project,
- 'title' => t('Swimlanes')
)));
}
@@ -60,18 +74,35 @@ class Swimlane extends Base
{
$project = $this->getProject();
$values = $this->request->getValues();
- list($valid, $errors) = $this->swimlane->validateCreation($values);
+ list($valid, $errors) = $this->swimlaneValidator->validateCreation($values);
if ($valid) {
if ($this->swimlane->create($values)) {
- $this->session->flash(t('Your swimlane have been created successfully.'));
+ $this->flash->success(t('Your swimlane have been created successfully.'));
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to create your swimlane.'));
+ $errors = array('name' => array(t('Another swimlane with the same name exists in the project')));
}
}
- $this->index($values, $errors);
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Edit default swimlane (display the form)
+ *
+ * @access public
+ */
+ public function editDefault(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+ $swimlane = $this->swimlane->getDefault($project['id']);
+
+ $this->response->html($this->helper->layout->project('swimlane/edit_default', array(
+ 'values' => empty($values) ? $swimlane : $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ )));
}
/**
@@ -79,23 +110,23 @@ class Swimlane extends Base
*
* @access public
*/
- public function change()
+ public function updateDefault()
{
$project = $this->getProject();
$values = $this->request->getValues() + array('show_default_swimlane' => 0);
- list($valid, ) = $this->swimlane->validateDefaultModification($values);
+ list($valid, $errors) = $this->swimlaneValidator->validateDefaultModification($values);
if ($valid) {
if ($this->swimlane->updateDefault($values)) {
- $this->session->flash(t('The default swimlane have been updated successfully.'));
- $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
+ $this->flash->success(t('The default swimlane have been updated successfully.'));
+ $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])), true);
} else {
- $this->session->flashError(t('Unable to update this swimlane.'));
+ $this->flash->failure(t('Unable to update this swimlane.'));
}
}
- $this->index();
+ $this->editDefault($values, $errors);
}
/**
@@ -108,11 +139,10 @@ class Swimlane extends Base
$project = $this->getProject();
$swimlane = $this->getSwimlane($project['id']);
- $this->response->html($this->projectLayout('swimlane/edit', array(
+ $this->response->html($this->helper->layout->project('swimlane/edit', array(
'values' => empty($values) ? $swimlane : $values,
'errors' => $errors,
'project' => $project,
- 'title' => t('Swimlanes')
)));
}
@@ -126,14 +156,14 @@ class Swimlane extends Base
$project = $this->getProject();
$values = $this->request->getValues();
- list($valid, $errors) = $this->swimlane->validateModification($values);
+ list($valid, $errors) = $this->swimlaneValidator->validateModification($values);
if ($valid) {
if ($this->swimlane->update($values)) {
- $this->session->flash(t('Swimlane updated successfully.'));
+ $this->flash->success(t('Swimlane updated successfully.'));
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
} else {
- $this->session->flashError(t('Unable to update this swimlane.'));
+ $errors = array('name' => array(t('Another swimlane with the same name exists in the project')));
}
}
@@ -150,10 +180,9 @@ class Swimlane extends Base
$project = $this->getProject();
$swimlane = $this->getSwimlane($project['id']);
- $this->response->html($this->projectLayout('swimlane/remove', array(
+ $this->response->html($this->helper->layout->project('swimlane/remove', array(
'project' => $project,
'swimlane' => $swimlane,
- 'title' => t('Remove a swimlane')
)));
}
@@ -169,9 +198,9 @@ class Swimlane extends Base
$swimlane_id = $this->request->getIntegerParam('swimlane_id');
if ($this->swimlane->remove($project['id'], $swimlane_id)) {
- $this->session->flash(t('Swimlane removed successfully.'));
+ $this->flash->success(t('Swimlane removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this swimlane.'));
+ $this->flash->failure(t('Unable to remove this swimlane.'));
}
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
@@ -189,9 +218,28 @@ class Swimlane extends Base
$swimlane_id = $this->request->getIntegerParam('swimlane_id');
if ($this->swimlane->disable($project['id'], $swimlane_id)) {
- $this->session->flash(t('Swimlane updated successfully.'));
+ $this->flash->success(t('Swimlane updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update this swimlane.'));
+ $this->flash->failure(t('Unable to update this swimlane.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Disable default swimlane
+ *
+ * @access public
+ */
+ public function disableDefault()
+ {
+ $this->checkCSRFParam();
+ $project = $this->getProject();
+
+ if ($this->swimlane->disableDefault($project['id'])) {
+ $this->flash->success(t('Swimlane updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this swimlane.'));
}
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
@@ -209,41 +257,48 @@ class Swimlane extends Base
$swimlane_id = $this->request->getIntegerParam('swimlane_id');
if ($this->swimlane->enable($project['id'], $swimlane_id)) {
- $this->session->flash(t('Swimlane updated successfully.'));
+ $this->flash->success(t('Swimlane updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update this swimlane.'));
+ $this->flash->failure(t('Unable to update this swimlane.'));
}
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
}
/**
- * Move up a swimlane
+ * Enable default swimlane
*
* @access public
*/
- public function moveup()
+ public function enableDefault()
{
$this->checkCSRFParam();
$project = $this->getProject();
- $swimlane_id = $this->request->getIntegerParam('swimlane_id');
- $this->swimlane->moveUp($project['id'], $swimlane_id);
+ if ($this->swimlane->enableDefault($project['id'])) {
+ $this->flash->success(t('Swimlane updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this swimlane.'));
+ }
+
$this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
}
/**
- * Move down a swimlane
+ * Move swimlane position
*
* @access public
*/
- public function movedown()
+ public function move()
{
- $this->checkCSRFParam();
$project = $this->getProject();
- $swimlane_id = $this->request->getIntegerParam('swimlane_id');
+ $values = $this->request->getJson();
- $this->swimlane->moveDown($project['id'], $swimlane_id);
- $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id'])));
+ if (! empty($values) && isset($values['swimlane_id']) && isset($values['position'])) {
+ $result = $this->swimlane->changePosition($project['id'], $values['swimlane_id'], $values['position']);
+ return $this->response->json(array('result' => $result));
+ }
+
+ $this->forbidden();
}
}
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
index 894802d8..539d377b 100644
--- a/app/Controller/Task.php
+++ b/app/Controller/Task.php
@@ -30,13 +30,13 @@ class Task extends Base
$this->notfound(true);
}
- $this->response->html($this->template->layout('task/public', array(
+ $this->response->html($this->helper->layout->app('task/public', array(
'project' => $project,
'comments' => $this->comment->getAll($task['id']),
'subtasks' => $this->subtask->getAll($task['id']),
'links' => $this->taskLink->getAllGroupedByLabel($task['id']),
'task' => $task,
- 'columns_list' => $this->board->getColumnsList($task['project_id']),
+ 'columns_list' => $this->column->getList($task['project_id']),
'colors_list' => $this->color->getList(),
'title' => $task['title'],
'no_layout' => true,
@@ -62,23 +62,21 @@ class Task extends Base
'time_spent' => $task['time_spent'] ?: '',
);
- $this->dateParser->format($values, array('date_started'), 'Y-m-d H:i');
+ $values = $this->dateParser->format($values, array('date_started'), $this->config->get('application_datetime_format', 'm/d/Y H:i'));
- $this->response->html($this->taskLayout('task/show', array(
+ $this->response->html($this->helper->layout->task('task/show', array(
'project' => $this->project->getById($task['project_id']),
- 'files' => $this->file->getAllDocuments($task['id']),
- 'images' => $this->file->getAllImages($task['id']),
+ 'files' => $this->taskFile->getAllDocuments($task['id']),
+ 'images' => $this->taskFile->getAllImages($task['id']),
'comments' => $this->comment->getAll($task['id'], $this->userSession->getCommentSorting()),
'subtasks' => $subtasks,
'links' => $this->taskLink->getAllGroupedByLabel($task['id']),
'task' => $task,
'values' => $values,
'link_label_list' => $this->link->getList(0, false),
- 'columns_list' => $this->board->getColumnsList($task['project_id']),
+ 'columns_list' => $this->column->getList($task['project_id']),
'colors_list' => $this->color->getList(),
- 'users_list' => $this->projectPermission->getMemberList($task['project_id'], true, false, false),
- 'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id'], true, false, false),
'title' => $task['project_name'].' &gt; '.$task['title'],
'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(),
'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(),
@@ -95,7 +93,7 @@ class Task extends Base
{
$task = $this->getTask();
- $this->response->html($this->taskLayout('task/analytics', array(
+ $this->response->html($this->helper->layout->task('task/analytics', array(
'title' => $task['title'],
'task' => $task,
'lead_time' => $this->taskAnalytic->getLeadTime($task),
@@ -114,14 +112,14 @@ class Task extends Base
$task = $this->getTask();
$subtask_paginator = $this->paginator
- ->setUrl('task', 'timesheet', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'pagination' => 'subtasks'))
+ ->setUrl('task', 'timetracking', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'pagination' => 'subtasks'))
->setMax(15)
->setOrder('start')
->setDirection('DESC')
->setQuery($this->subtaskTimeTracking->getTaskQuery($task['id']))
->calculateOnlyIf($this->request->getStringParam('pagination') === 'subtasks');
- $this->response->html($this->taskLayout('task/time_tracking_details', array(
+ $this->response->html($this->helper->layout->task('task/time_tracking_details', array(
'task' => $task,
'subtask_paginator' => $subtask_paginator,
)));
@@ -136,7 +134,7 @@ class Task extends Base
{
$task = $this->getTask();
- $this->response->html($this->taskLayout('task/transitions', array(
+ $this->response->html($this->helper->layout->task('task/transitions', array(
'task' => $task,
'transitions' => $this->transition->getAllByTask($task['id']),
)));
@@ -159,15 +157,15 @@ class Task extends Base
$this->checkCSRFParam();
if ($this->task->remove($task['id'])) {
- $this->session->flash(t('Task removed successfully.'));
+ $this->flash->success(t('Task removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this task.'));
+ $this->flash->failure(t('Unable to remove this task.'));
}
$this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
}
- $this->response->html($this->taskLayout('task/remove', array(
+ $this->response->html($this->helper->layout->task('task/remove', array(
'task' => $task,
)));
}
diff --git a/app/Controller/TaskExternalLink.php b/app/Controller/TaskExternalLink.php
new file mode 100644
index 00000000..f26922dd
--- /dev/null
+++ b/app/Controller/TaskExternalLink.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Kanboard\Core\ExternalLink\ExternalLinkProviderNotFound;
+
+/**
+ * Task External Link Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class TaskExternalLink extends Base
+{
+ /**
+ * Creation form
+ *
+ * @access public
+ */
+ public function show()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->helper->layout->task('task_external_link/show', array(
+ 'links' => $this->taskExternalLink->getAll($task['id']),
+ 'task' => $task,
+ 'title' => t('List of external links'),
+ )));
+ }
+
+ /**
+ * First creation form
+ *
+ * @access public
+ */
+ public function find(array $values = array(), array $errors = array())
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->helper->layout->task('task_external_link/find', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'task' => $task,
+ 'types' => $this->externalLinkManager->getTypes(),
+ )));
+ }
+
+ /**
+ * Second creation form
+ *
+ * @access public
+ */
+ public function create()
+ {
+ try {
+
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+
+ $provider = $this->externalLinkManager->setUserInput($values)->find();
+ $link = $provider->getLink();
+
+ $this->response->html($this->helper->layout->task('task_external_link/create', array(
+ 'values' => array(
+ 'title' => $link->getTitle(),
+ 'url' => $link->getUrl(),
+ 'link_type' => $provider->getType(),
+ ),
+ 'dependencies' => $provider->getDependencies(),
+ 'errors' => array(),
+ 'task' => $task,
+ )));
+
+ } catch (ExternalLinkProviderNotFound $e) {
+ $errors = array('text' => array(t('Unable to fetch link information.')));
+ $this->find($values, $errors);
+ }
+ }
+
+ /**
+ * Save link
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->externalLinkValidator->validateCreation($values);
+
+ if ($valid && $this->taskExternalLink->create($values)) {
+ $this->flash->success(t('Link added successfully.'));
+ return $this->response->redirect($this->helper->url->to('TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), true);
+ }
+
+ $this->edit($values, $errors);
+ }
+
+ /**
+ * Edit form
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $task = $this->getTask();
+ $link_id = $this->request->getIntegerParam('link_id');
+
+ if ($link_id > 0) {
+ $values = $this->taskExternalLink->getById($link_id);
+ }
+
+ if (empty($values)) {
+ return $this->notfound();
+ }
+
+ $provider = $this->externalLinkManager->getProvider($values['link_type']);
+
+ $this->response->html($this->helper->layout->task('task_external_link/edit', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'task' => $task,
+ 'dependencies' => $provider->getDependencies(),
+ )));
+ }
+
+ /**
+ * Update link
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->externalLinkValidator->validateModification($values);
+
+ if ($valid && $this->taskExternalLink->update($values)) {
+ $this->flash->success(t('Link updated successfully.'));
+ return $this->response->redirect($this->helper->url->to('TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), true);
+ }
+
+ $this->edit($values, $errors);
+ }
+
+ /**
+ * Confirmation dialog before removing a link
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $task = $this->getTask();
+ $link_id = $this->request->getIntegerParam('link_id');
+ $link = $this->taskExternalLink->getById($link_id);
+
+ if (empty($link)) {
+ return $this->notfound();
+ }
+
+ $this->response->html($this->helper->layout->task('task_external_link/remove', array(
+ 'link' => $link,
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $task = $this->getTask();
+
+ if ($this->taskExternalLink->remove($this->request->getIntegerParam('link_id'))) {
+ $this->flash->success(t('Link removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this link.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
+ }
+}
diff --git a/app/Controller/TaskFile.php b/app/Controller/TaskFile.php
new file mode 100644
index 00000000..2b0152a7
--- /dev/null
+++ b/app/Controller/TaskFile.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Task File Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class TaskFile extends Base
+{
+ /**
+ * Screenshot
+ *
+ * @access public
+ */
+ public function screenshot()
+ {
+ $task = $this->getTask();
+
+ if ($this->request->isPost() && $this->taskFile->uploadScreenshot($task['id'], $this->request->getValue('screenshot')) !== false) {
+ $this->flash->success(t('Screenshot uploaded successfully.'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), true);
+ }
+
+ $this->response->html($this->template->render('task_file/screenshot', array(
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * File upload form
+ *
+ * @access public
+ */
+ public function create()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->template->render('task_file/create', array(
+ 'task' => $task,
+ 'max_size' => $this->helper->text->phpToBytes(ini_get('upload_max_filesize')),
+ )));
+ }
+
+ /**
+ * File upload (save files)
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $task = $this->getTask();
+
+ if (! $this->taskFile->uploadFiles($task['id'], $this->request->getFileInfo('files'))) {
+ $this->flash->failure(t('Unable to upload the file.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), true);
+ }
+
+ /**
+ * Remove a file
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $task = $this->getTask();
+ $file = $this->taskFile->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] == $task['id'] && $this->taskFile->remove($file['id'])) {
+ $this->flash->success(t('File removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this file.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
+ }
+
+ /**
+ * Confirmation dialog before removing a file
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $task = $this->getTask();
+ $file = $this->taskFile->getById($this->request->getIntegerParam('file_id'));
+
+ $this->response->html($this->template->render('task_file/remove', array(
+ 'task' => $task,
+ 'file' => $file,
+ )));
+ }
+}
diff --git a/app/Controller/TaskHelper.php b/app/Controller/TaskHelper.php
new file mode 100644
index 00000000..236af33e
--- /dev/null
+++ b/app/Controller/TaskHelper.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Task Ajax Helper
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class TaskHelper extends Base
+{
+ /**
+ * Render Markdown text and reply with the HTML Code
+ *
+ * @access public
+ */
+ public function preview()
+ {
+ $payload = $this->request->getJson();
+
+ if (empty($payload['text'])) {
+ $this->response->html('<p>'.t('Nothing to preview...').'</p>');
+ }
+
+ $this->response->html($this->helper->text->markdown($payload['text']));
+ }
+
+ /**
+ * Task autocompletion (Ajax)
+ *
+ * @access public
+ */
+ public function autocomplete()
+ {
+ $search = $this->request->getStringParam('term');
+ $projects = $this->projectPermission->getActiveProjectIds($this->userSession->getId());
+
+ if (empty($projects)) {
+ $this->response->json(array());
+ }
+
+ $filter = $this->taskFilterAutoCompleteFormatter
+ ->create()
+ ->filterByProjects($projects)
+ ->excludeTasks(array($this->request->getIntegerParam('exclude_task_id')));
+
+ // Search by task id or by title
+ if (ctype_digit($search)) {
+ $filter->filterById($search);
+ } else {
+ $filter->filterByTitle($search);
+ }
+
+ $this->response->json($filter->format());
+ }
+}
diff --git a/app/Controller/TaskImport.php b/app/Controller/TaskImport.php
index 0e9d2169..460c608c 100644
--- a/app/Controller/TaskImport.php
+++ b/app/Controller/TaskImport.php
@@ -20,7 +20,7 @@ class TaskImport extends Base
{
$project = $this->getProject();
- $this->response->html($this->projectLayout('task_import/step1', array(
+ $this->response->html($this->helper->layout->project('task_import/step1', array(
'project' => $project,
'values' => $values,
'errors' => $errors,
@@ -52,9 +52,9 @@ class TaskImport extends Base
$csv->read($filename, array($this->taskImport, 'import'));
if ($this->taskImport->counter > 0) {
- $this->session->flash(t('%d task(s) have been imported successfully.', $this->taskImport->counter));
+ $this->flash->success(t('%d task(s) have been imported successfully.', $this->taskImport->counter));
} else {
- $this->session->flashError(t('Nothing have been imported!'));
+ $this->flash->failure(t('Nothing have been imported!'));
}
$this->response->redirect($this->helper->url->to('taskImport', 'step1', array('project_id' => $project['id'])));
diff --git a/app/Controller/TaskRecurrence.php b/app/Controller/TaskRecurrence.php
new file mode 100644
index 00000000..f02f3cdc
--- /dev/null
+++ b/app/Controller/TaskRecurrence.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * Task Recurrence controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class TaskRecurrence extends Base
+{
+ /**
+ * Edit recurrence form
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $task = $this->getTask();
+
+ if (empty($values)) {
+ $values = $task;
+ }
+
+ $this->response->html($this->helper->layout->task('task_recurrence/edit', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'task' => $task,
+ 'recurrence_status_list' => $this->task->getRecurrenceStatusList(),
+ 'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(),
+ 'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(),
+ 'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(),
+ )));
+ }
+
+ /**
+ * Update recurrence form
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+
+ list($valid, $errors) = $this->taskValidator->validateEditRecurrence($values);
+
+ if ($valid) {
+ if ($this->taskModification->update($values)) {
+ $this->flash->success(t('Task updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update your task.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
+ }
+
+ $this->edit($values, $errors);
+ }
+}
diff --git a/app/Controller/Taskcreation.php b/app/Controller/Taskcreation.php
index e47cd1b7..1d8a0e29 100644
--- a/app/Controller/Taskcreation.php
+++ b/app/Controller/Taskcreation.php
@@ -18,30 +18,29 @@ class Taskcreation extends Base
public function create(array $values = array(), array $errors = array())
{
$project = $this->getProject();
- $method = $this->request->isAjax() ? 'render' : 'layout';
$swimlanes_list = $this->swimlane->getList($project['id'], false, true);
if (empty($values)) {
$values = array(
'swimlane_id' => $this->request->getIntegerParam('swimlane_id', key($swimlanes_list)),
'column_id' => $this->request->getIntegerParam('column_id'),
- 'color_id' => $this->request->getStringParam('color_id', $this->color->getDefaultColor()),
- 'owner_id' => $this->request->getIntegerParam('owner_id'),
- 'another_task' => $this->request->getIntegerParam('another_task'),
+ 'color_id' => $this->color->getDefaultColor(),
+ 'owner_id' => $this->userSession->getId(),
);
+
+ $values = $this->hook->merge('controller:task:form:default', $values, array('default_values' => $values));
+ $values = $this->hook->merge('controller:task-creation:form:default', $values, array('default_values' => $values));
}
- $this->response->html($this->template->$method('task_creation/form', array(
- 'ajax' => $this->request->isAjax(),
+ $this->response->html($this->template->render('task_creation/form', array(
+ 'project' => $project,
'errors' => $errors,
'values' => $values + array('project_id' => $project['id']),
- 'columns_list' => $this->board->getColumnsList($project['id']),
- 'users_list' => $this->projectPermission->getMemberList($project['id'], true, false, true),
+ 'columns_list' => $this->column->getList($project['id']),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id'], true, false, true),
'colors_list' => $this->color->getList(),
'categories_list' => $this->category->getList($project['id']),
'swimlanes_list' => $swimlanes_list,
- 'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
'title' => $project['name'].' &gt; '.t('New task')
)));
}
@@ -59,26 +58,27 @@ class Taskcreation extends Base
list($valid, $errors) = $this->taskValidator->validateCreation($values);
if ($valid && $this->taskCreation->create($values)) {
- $this->session->flash(t('Task created successfully.'));
- $this->afterSave($project, $values);
- } else {
- $this->session->flashError(t('Unable to create your task.'));
+ $this->flash->success(t('Task created successfully.'));
+ return $this->afterSave($project, $values);
}
+ $this->flash->failure(t('Unable to create your task.'));
$this->create($values, $errors);
}
private function afterSave(array $project, array &$values)
{
if (isset($values['another_task']) && $values['another_task'] == 1) {
- unset($values['title']);
- unset($values['description']);
-
- if (! $this->request->isAjax()) {
- $this->response->redirect($this->helper->url->to('taskcreation', 'create', $values));
- }
- } else {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project['id'])));
+ return $this->create(array(
+ 'owner_id' => $values['owner_id'],
+ 'color_id' => $values['color_id'],
+ 'category_id' => isset($values['category_id']) ? $values['category_id'] : 0,
+ 'column_id' => $values['column_id'],
+ 'swimlane_id' => isset($values['swimlane_id']) ? $values['swimlane_id'] : 0,
+ 'another_task' => 1,
+ ));
}
+
+ $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project['id'])));
}
}
diff --git a/app/Controller/Taskduplication.php b/app/Controller/Taskduplication.php
index 79f498fc..7641a48d 100644
--- a/app/Controller/Taskduplication.php
+++ b/app/Controller/Taskduplication.php
@@ -24,15 +24,15 @@ class Taskduplication extends Base
$task_id = $this->taskDuplication->duplicate($task['id']);
if ($task_id > 0) {
- $this->session->flash(t('Task created successfully.'));
+ $this->flash->success(t('Task created successfully.'));
$this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task_id)));
} else {
- $this->session->flashError(t('Unable to create this task.'));
- $this->response->redirect($this->helper->url->to('taskduplication', 'duplicate', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
+ $this->flash->failure(t('Unable to create this task.'));
+ $this->response->redirect($this->helper->url->to('taskduplication', 'duplicate', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
}
}
- $this->response->html($this->taskLayout('task_duplication/duplicate', array(
+ $this->response->html($this->helper->layout->task('task_duplication/duplicate', array(
'task' => $task,
)));
}
@@ -56,11 +56,11 @@ class Taskduplication extends Base
$values['column_id'],
$values['category_id'],
$values['owner_id'])) {
- $this->session->flash(t('Task updated successfully.'));
+ $this->flash->success(t('Task updated successfully.'));
$this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $values['project_id'], 'task_id' => $task['id'])));
}
- $this->session->flashError(t('Unable to update your task.'));
+ $this->flash->failure(t('Unable to update your task.'));
}
$this->chooseDestination($task, 'task_duplication/move');
@@ -86,12 +86,12 @@ class Taskduplication extends Base
);
if ($task_id > 0) {
- $this->session->flash(t('Task created successfully.'));
+ $this->flash->success(t('Task created successfully.'));
$this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $values['project_id'], 'task_id' => $task_id)));
}
}
- $this->session->flashError(t('Unable to create your task.'));
+ $this->flash->failure(t('Unable to create your task.'));
}
$this->chooseDestination($task, 'task_duplication/copy');
@@ -107,7 +107,7 @@ class Taskduplication extends Base
private function chooseDestination(array $task, $template)
{
$values = array();
- $projects_list = $this->projectPermission->getActiveMemberProjects($this->userSession->getId());
+ $projects_list = $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId());
unset($projects_list[$task['project_id']]);
@@ -115,9 +115,9 @@ class Taskduplication extends Base
$dst_project_id = $this->request->getIntegerParam('dst_project_id', key($projects_list));
$swimlanes_list = $this->swimlane->getList($dst_project_id, false, true);
- $columns_list = $this->board->getColumnsList($dst_project_id);
+ $columns_list = $this->column->getList($dst_project_id);
$categories_list = $this->category->getList($dst_project_id);
- $users_list = $this->projectPermission->getMemberList($dst_project_id);
+ $users_list = $this->projectUserRole->getAssignableUsersList($dst_project_id);
$values = $this->taskDuplication->checkDestinationProjectValues($task);
$values['project_id'] = $dst_project_id;
@@ -128,7 +128,7 @@ class Taskduplication extends Base
$users_list = array();
}
- $this->response->html($this->taskLayout($template, array(
+ $this->response->html($this->helper->layout->task($template, array(
'values' => $values,
'task' => $task,
'projects_list' => $projects_list,
diff --git a/app/Controller/Tasklink.php b/app/Controller/Tasklink.php
index 587769ee..fdb4fada 100644
--- a/app/Controller/Tasklink.php
+++ b/app/Controller/Tasklink.php
@@ -22,13 +22,32 @@ class Tasklink extends Base
$link = $this->taskLink->getById($this->request->getIntegerParam('link_id'));
if (empty($link)) {
- $this->notfound();
+ return $this->notfound();
}
return $link;
}
/**
+ * Show links
+ *
+ * @access public
+ */
+ public function show()
+ {
+ $task = $this->getTask();
+ $project = $this->project->getById($task['project_id']);
+
+ $this->response->html($this->helper->layout->task('tasklink/show', array(
+ 'links' => $this->taskLink->getAllGroupedByLabel($task['id']),
+ 'task' => $task,
+ 'project' => $project,
+ 'editable' => true,
+ 'is_public' => false,
+ )));
+ }
+
+ /**
* Creation form
*
* @access public
@@ -36,20 +55,8 @@ class Tasklink extends Base
public function create(array $values = array(), array $errors = array())
{
$task = $this->getTask();
- $ajax = $this->request->isAjax() || $this->request->getIntegerParam('ajax');
-
- if ($ajax && empty($errors)) {
- $this->response->html($this->template->render('tasklink/create', array(
- 'values' => $values,
- 'errors' => $errors,
- 'task' => $task,
- 'labels' => $this->link->getList(0, false),
- 'title' => t('Add a new link'),
- 'ajax' => $ajax,
- )));
- }
- $this->response->html($this->taskLayout('tasklink/create', array(
+ $this->response->html($this->helper->layout->task('tasklink/create', array(
'values' => $values,
'errors' => $errors,
'task' => $task,
@@ -67,23 +74,17 @@ class Tasklink extends Base
{
$task = $this->getTask();
$values = $this->request->getValues();
- $ajax = $this->request->isAjax() || $this->request->getIntegerParam('ajax');
- list($valid, $errors) = $this->taskLink->validateCreation($values);
+ list($valid, $errors) = $this->taskLinkValidator->validateCreation($values);
if ($valid) {
if ($this->taskLink->create($values['task_id'], $values['opposite_task_id'], $values['link_id'])) {
- $this->session->flash(t('Link added successfully.'));
-
- if ($ajax) {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links');
+ $this->flash->success(t('Link added successfully.'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links', true);
}
$errors = array('title' => array(t('The exact same link already exists')));
- $this->session->flashError(t('Unable to create your link.'));
+ $this->flash->failure(t('Unable to create your link.'));
}
$this->create($values, $errors);
@@ -105,7 +106,7 @@ class Tasklink extends Base
$values['title'] = '#'.$opposite_task['id'].' - '.$opposite_task['title'];
}
- $this->response->html($this->taskLayout('tasklink/edit', array(
+ $this->response->html($this->helper->layout->task('tasklink/edit', array(
'values' => $values,
'errors' => $errors,
'task_link' => $task_link,
@@ -125,15 +126,15 @@ class Tasklink extends Base
$task = $this->getTask();
$values = $this->request->getValues();
- list($valid, $errors) = $this->taskLink->validateModification($values);
+ list($valid, $errors) = $this->taskLinkValidator->validateModification($values);
if ($valid) {
if ($this->taskLink->update($values['id'], $values['task_id'], $values['opposite_task_id'], $values['link_id'])) {
- $this->session->flash(t('Link updated successfully.'));
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links');
+ $this->flash->success(t('Link updated successfully.'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links');
}
- $this->session->flashError(t('Unable to update your link.'));
+ $this->flash->failure(t('Unable to update your link.'));
}
$this->edit($values, $errors);
@@ -149,7 +150,7 @@ class Tasklink extends Base
$task = $this->getTask();
$link = $this->getTaskLink();
- $this->response->html($this->taskLayout('tasklink/remove', array(
+ $this->response->html($this->helper->layout->task('tasklink/remove', array(
'link' => $link,
'task' => $task,
)));
@@ -166,9 +167,9 @@ class Tasklink extends Base
$task = $this->getTask();
if ($this->taskLink->remove($this->request->getIntegerParam('link_id'))) {
- $this->session->flash(t('Link removed successfully.'));
+ $this->flash->success(t('Link removed successfully.'));
} else {
- $this->session->flashError(t('Unable to remove this link.'));
+ $this->flash->failure(t('Unable to remove this link.'));
}
$this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links');
diff --git a/app/Controller/Taskmodification.php b/app/Controller/Taskmodification.php
index b1105dcc..306d34c0 100644
--- a/app/Controller/Taskmodification.php
+++ b/app/Controller/Taskmodification.php
@@ -23,71 +23,48 @@ class Taskmodification extends Base
}
/**
- * Update time tracking information
+ * Edit description form
*
* @access public
*/
- public function time()
+ public function description(array $values = array(), array $errors = array())
{
$task = $this->getTask();
- $values = $this->request->getValues();
- list($valid, ) = $this->taskValidator->validateTimeModification($values);
-
- if ($valid && $this->taskModification->update($values)) {
- $this->session->flash(t('Task updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update your task.'));
+ if (empty($values)) {
+ $values = array('id' => $task['id'], 'description' => $task['description']);
}
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
+ $this->response->html($this->helper->layout->task('task_modification/edit_description', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'task' => $task,
+ )));
}
/**
- * Edit description form
+ * Update description
*
* @access public
*/
- public function description()
+ public function updateDescription()
{
$task = $this->getTask();
- $ajax = $this->request->isAjax() || $this->request->getIntegerParam('ajax');
-
- if ($this->request->isPost()) {
- $values = $this->request->getValues();
-
- list($valid, $errors) = $this->taskValidator->validateDescriptionCreation($values);
+ $values = $this->request->getValues();
- if ($valid) {
- if ($this->taskModification->update($values)) {
- $this->session->flash(t('Task updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update your task.'));
- }
+ list($valid, $errors) = $this->taskValidator->validateDescriptionCreation($values);
- if ($ajax) {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- } else {
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
- }
+ if ($valid) {
+ if ($this->taskModification->update($values)) {
+ $this->flash->success(t('Task updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update your task.'));
}
- } else {
- $values = $task;
- $errors = array();
- }
- $params = array(
- 'values' => $values,
- 'errors' => $errors,
- 'task' => $task,
- 'ajax' => $ajax,
- );
-
- if ($ajax) {
- $this->response->html($this->template->render('task_modification/edit_description', $params));
- } else {
- $this->response->html($this->taskLayout('task_modification/edit_description', $params));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
}
+
+ $this->description($values, $errors);
}
/**
@@ -98,33 +75,26 @@ class Taskmodification extends Base
public function edit(array $values = array(), array $errors = array())
{
$task = $this->getTask();
- $ajax = $this->request->isAjax();
+ $project = $this->project->getById($task['project_id']);
if (empty($values)) {
$values = $task;
+ $values = $this->hook->merge('controller:task:form:default', $values, array('default_values' => $values));
+ $values = $this->hook->merge('controller:task-modification:form:default', $values, array('default_values' => $values));
}
- $this->dateParser->format($values, array('date_due'));
+ $values = $this->dateParser->format($values, array('date_due'), $this->config->get('application_date_format', 'm/d/Y'));
+ $values = $this->dateParser->format($values, array('date_started'), $this->config->get('application_datetime_format', 'm/d/Y H:i'));
- $params = array(
+ $this->response->html($this->helper->layout->task('task_modification/edit_task', array(
+ 'project' => $project,
'values' => $values,
'errors' => $errors,
'task' => $task,
- 'users_list' => $this->projectPermission->getMemberList($task['project_id']),
+ 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']),
'colors_list' => $this->color->getList(),
'categories_list' => $this->category->getList($task['project_id']),
- 'date_format' => $this->config->get('application_date_format'),
- 'date_formats' => $this->dateParser->getAvailableFormats(),
- 'ajax' => $ajax,
- );
-
- if ($ajax) {
- $html = $this->template->render('task_modification/edit_task', $params);
- } else {
- $html = $this->taskLayout('task_modification/edit_task', $params);
- }
-
- $this->response->html($html);
+ )));
}
/**
@@ -140,57 +110,11 @@ class Taskmodification extends Base
list($valid, $errors) = $this->taskValidator->validateModification($values);
if ($valid && $this->taskModification->update($values)) {
- $this->session->flash(t('Task updated successfully.'));
-
- if ($this->request->isAjax()) {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- } else {
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
- }
+ $this->flash->success(t('Task updated successfully.'));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])), true);
} else {
- $this->session->flashError(t('Unable to update your task.'));
+ $this->flash->failure(t('Unable to update your task.'));
$this->edit($values, $errors);
}
}
-
- /**
- * Edit recurrence form
- *
- * @access public
- */
- public function recurrence()
- {
- $task = $this->getTask();
-
- if ($this->request->isPost()) {
- $values = $this->request->getValues();
-
- list($valid, $errors) = $this->taskValidator->validateEditRecurrence($values);
-
- if ($valid) {
- if ($this->taskModification->update($values)) {
- $this->session->flash(t('Task updated successfully.'));
- } else {
- $this->session->flashError(t('Unable to update your task.'));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
- }
- } else {
- $values = $task;
- $errors = array();
- }
-
- $params = array(
- 'values' => $values,
- 'errors' => $errors,
- 'task' => $task,
- 'recurrence_status_list' => $this->task->getRecurrenceStatusList(),
- 'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(),
- 'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(),
- 'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(),
- );
-
- $this->response->html($this->taskLayout('task_modification/edit_recurrence', $params));
- }
}
diff --git a/app/Controller/Taskstatus.php b/app/Controller/Taskstatus.php
index c0421ea7..c07f2cc5 100644
--- a/app/Controller/Taskstatus.php
+++ b/app/Controller/Taskstatus.php
@@ -17,9 +17,7 @@ class Taskstatus extends Base
*/
public function close()
{
- $task = $this->getTask();
- $this->changeStatus($task, 'close', t('Task closed successfully.'), t('Unable to close this task.'));
- $this->renderTemplate($task, 'task_status/close');
+ $this->changeStatus('close', 'task_status/close', t('Task closed successfully.'), t('Unable to close this task.'));
}
/**
@@ -29,44 +27,36 @@ class Taskstatus extends Base
*/
public function open()
{
- $task = $this->getTask();
- $this->changeStatus($task, 'open', t('Task opened successfully.'), t('Unable to open this task.'));
- $this->renderTemplate($task, 'task_status/open');
+ $this->changeStatus('open', 'task_status/open', t('Task opened successfully.'), t('Unable to open this task.'));
}
- private function changeStatus(array $task, $method, $success_message, $failure_message)
+ /**
+ * Common method to change status
+ *
+ * @access private
+ * @param string $method
+ * @param string $template
+ * @param string $success_message
+ * @param string $failure_message
+ */
+ private function changeStatus($method, $template, $success_message, $failure_message)
{
+ $task = $this->getTask();
+
if ($this->request->getStringParam('confirmation') === 'yes') {
$this->checkCSRFParam();
if ($this->taskStatus->$method($task['id'])) {
- $this->session->flash($success_message);
+ $this->flash->success($success_message);
} else {
- $this->session->flashError($failure_message);
+ $this->flash->failure($failure_message);
}
- if ($this->request->getStringParam('redirect') === 'board') {
- $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id'])));
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
- }
-
- private function renderTemplate(array $task, $template)
- {
- $redirect = $this->request->getStringParam('redirect');
-
- if ($this->request->isAjax()) {
- $this->response->html($this->template->render($template, array(
- 'task' => $task,
- 'redirect' => $redirect,
- )));
+ return $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), true);
}
- $this->response->html($this->taskLayout($template, array(
+ $this->response->html($this->helper->layout->task($template, array(
'task' => $task,
- 'redirect' => $redirect,
)));
}
}
diff --git a/app/Controller/Timer.php b/app/Controller/Timer.php
deleted file mode 100644
index 0267fcdd..00000000
--- a/app/Controller/Timer.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-namespace Kanboard\Controller;
-
-/**
- * Time Tracking controller
- *
- * @package controller
- * @author Frederic Guillot
- */
-class Timer extends Base
-{
- /**
- * Start/stop timer for subtasks
- *
- * @access public
- */
- public function subtask()
- {
- $project_id = $this->request->getIntegerParam('project_id');
- $task_id = $this->request->getIntegerParam('task_id');
- $subtask_id = $this->request->getIntegerParam('subtask_id');
- $timer = $this->request->getStringParam('timer');
-
- if ($timer === 'start') {
- $this->subtaskTimeTracking->logStartTime($subtask_id, $this->userSession->getId());
- } elseif ($timer === 'stop') {
- $this->subtaskTimeTracking->logEndTime($subtask_id, $this->userSession->getId());
- $this->subtaskTimeTracking->updateTaskTimeTracking($task_id);
- }
-
- $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $project_id, 'task_id' => $task_id)).'#subtasks');
- }
-}
diff --git a/app/Controller/Twofactor.php b/app/Controller/Twofactor.php
index 179241f8..10292261 100644
--- a/app/Controller/Twofactor.php
+++ b/app/Controller/Twofactor.php
@@ -2,10 +2,6 @@
namespace Kanboard\Controller;
-use Otp\Otp;
-use Otp\GoogleAuthenticator;
-use Base32\Base32;
-
/**
* Two Factor Auth controller
*
@@ -27,7 +23,7 @@ class Twofactor extends User
}
/**
- * Index
+ * Show form to disable/enable 2FA
*
* @access public
*/
@@ -35,68 +31,98 @@ class Twofactor extends User
{
$user = $this->getUser();
$this->checkCurrentUser($user);
+ unset($this->sessionStorage->twoFactorSecret);
+
+ $this->response->html($this->helper->layout->user('twofactor/index', array(
+ 'user' => $user,
+ 'provider' => $this->authenticationManager->getPostAuthenticationProvider()->getName(),
+ )));
+ }
+
+ /**
+ * Show page with secret and test form
+ *
+ * @access public
+ */
+ public function show()
+ {
+ $user = $this->getUser();
+ $this->checkCurrentUser($user);
$label = $user['email'] ?: $user['username'];
+ $provider = $this->authenticationManager->getPostAuthenticationProvider();
- $this->response->html($this->layout('twofactor/index', array(
+ if (! isset($this->sessionStorage->twoFactorSecret)) {
+ $provider->generateSecret();
+ $provider->beforeCode();
+ $this->sessionStorage->twoFactorSecret = $provider->getSecret();
+ } else {
+ $provider->setSecret($this->sessionStorage->twoFactorSecret);
+ }
+
+ $this->response->html($this->helper->layout->user('twofactor/show', array(
'user' => $user,
- 'qrcode_url' => $user['twofactor_activated'] == 1 ? GoogleAuthenticator::getQrCodeUrl('totp', $label, $user['twofactor_secret']) : '',
- 'key_url' => $user['twofactor_activated'] == 1 ? GoogleAuthenticator::getKeyUri('totp', $label, $user['twofactor_secret']) : '',
+ 'secret' => $this->sessionStorage->twoFactorSecret,
+ 'qrcode_url' => $provider->getQrCodeUrl($label),
+ 'key_url' => $provider->getKeyUrl($label),
)));
}
/**
- * Enable/disable 2FA
+ * Test code and save secret
*
* @access public
*/
- public function save()
+ public function test()
{
$user = $this->getUser();
$this->checkCurrentUser($user);
$values = $this->request->getValues();
- if (isset($values['twofactor_activated']) && $values['twofactor_activated'] == 1) {
+ $provider = $this->authenticationManager->getPostAuthenticationProvider();
+ $provider->setCode(empty($values['code']) ? '' : $values['code']);
+ $provider->setSecret($this->sessionStorage->twoFactorSecret);
+
+ if ($provider->authenticate()) {
+ $this->flash->success(t('The two factor authentication code is valid.'));
+
$this->user->update(array(
'id' => $user['id'],
'twofactor_activated' => 1,
- 'twofactor_secret' => GoogleAuthenticator::generateRandom(),
+ 'twofactor_secret' => $this->authenticationManager->getPostAuthenticationProvider()->getSecret(),
));
- } else {
- $this->user->update(array(
- 'id' => $user['id'],
- 'twofactor_activated' => 0,
- 'twofactor_secret' => '',
- ));
- }
- // Allow the user to test or disable the feature
- $_SESSION['user']['twofactor_activated'] = false;
+ unset($this->sessionStorage->twoFactorSecret);
+ $this->userSession->disablePostAuthentication();
- $this->session->flash(t('User updated successfully.'));
- $this->response->redirect($this->helper->url->to('twofactor', 'index', array('user_id' => $user['id'])));
+ $this->response->redirect($this->helper->url->to('twofactor', 'index', array('user_id' => $user['id'])));
+ } else {
+ $this->flash->failure(t('The two factor authentication code is not valid.'));
+ $this->response->redirect($this->helper->url->to('twofactor', 'show', array('user_id' => $user['id'])));
+ }
}
/**
- * Test 2FA
+ * Disable 2FA for the current user
*
* @access public
*/
- public function test()
+ public function deactivate()
{
$user = $this->getUser();
$this->checkCurrentUser($user);
- $otp = new Otp;
- $values = $this->request->getValues();
+ $this->user->update(array(
+ 'id' => $user['id'],
+ 'twofactor_activated' => 0,
+ 'twofactor_secret' => '',
+ ));
- if (! empty($values['code']) && $otp->checkTotp(Base32::decode($user['twofactor_secret']), $values['code'])) {
- $this->session->flash(t('The two factor authentication code is valid.'));
- } else {
- $this->session->flashError(t('The two factor authentication code is not valid.'));
- }
+ // Allow the user to test or disable the feature
+ $this->userSession->disablePostAuthentication();
+ $this->flash->success(t('User updated successfully.'));
$this->response->redirect($this->helper->url->to('twofactor', 'index', array('user_id' => $user['id'])));
}
@@ -110,15 +136,18 @@ class Twofactor extends User
$user = $this->getUser();
$this->checkCurrentUser($user);
- $otp = new Otp;
$values = $this->request->getValues();
- if (! empty($values['code']) && $otp->checkTotp(Base32::decode($user['twofactor_secret']), $values['code'])) {
- $this->session['2fa_validated'] = true;
- $this->session->flash(t('The two factor authentication code is valid.'));
+ $provider = $this->authenticationManager->getPostAuthenticationProvider();
+ $provider->setCode(empty($values['code']) ? '' : $values['code']);
+ $provider->setSecret($user['twofactor_secret']);
+
+ if ($provider->authenticate()) {
+ $this->userSession->validatePostAuthentication();
+ $this->flash->success(t('The two factor authentication code is valid.'));
$this->response->redirect($this->helper->url->to('app', 'index'));
} else {
- $this->session->flashError(t('The two factor authentication code is not valid.'));
+ $this->flash->failure(t('The two factor authentication code is not valid.'));
$this->response->redirect($this->helper->url->to('twofactor', 'code'));
}
}
@@ -130,7 +159,13 @@ class Twofactor extends User
*/
public function code()
{
- $this->response->html($this->template->layout('twofactor/check', array(
+ if (! isset($this->sessionStorage->twoFactorBeforeCodeCalled)) {
+ $provider = $this->authenticationManager->getPostAuthenticationProvider();
+ $provider->beforeCode();
+ $this->sessionStorage->twoFactorBeforeCodeCalled = true;
+ }
+
+ $this->response->html($this->helper->layout->app('twofactor/check', array(
'title' => t('Check two factor authentication code'),
)));
}
@@ -156,7 +191,7 @@ class Twofactor extends User
$this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user['id'])));
}
- $this->response->html($this->layout('twofactor/disable', array(
+ $this->response->html($this->helper->layout->user('twofactor/disable', array(
'user' => $user,
)));
}
diff --git a/app/Controller/User.php b/app/Controller/User.php
index b5f4edeb..f7d7d2e0 100644
--- a/app/Controller/User.php
+++ b/app/Controller/User.php
@@ -2,7 +2,9 @@
namespace Kanboard\Controller;
-use Kanboard\Model\NotificationType;
+use Kanboard\Notification\Mail as MailNotification;
+use Kanboard\Model\Project as ProjectModel;
+use Kanboard\Core\Security\Role;
/**
* User controller
@@ -13,27 +15,6 @@ use Kanboard\Model\NotificationType;
class User extends Base
{
/**
- * Common layout for user views
- *
- * @access protected
- * @param string $template Template name
- * @param array $params Template parameters
- * @return string
- */
- protected function layout($template, array $params)
- {
- $content = $this->template->render($template, $params);
- $params['user_content_for_layout'] = $content;
- $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
-
- if (isset($params['user'])) {
- $params['title'] = ($params['user']['name'] ?: $params['user']['username']).' (#'.$params['user']['id'].')';
- }
-
- return $this->template->layout('user/layout', $params);
- }
-
- /**
* List all users
*
* @access public
@@ -48,11 +29,32 @@ class User extends Base
->calculate();
$this->response->html(
- $this->template->layout('user/index', array(
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
+ $this->helper->layout->app('user/index', array(
'title' => t('Users').' ('.$paginator->getTotal().')',
'paginator' => $paginator,
- )));
+ )
+ ));
+ }
+
+ /**
+ * Public user profile
+ *
+ * @access public
+ */
+ public function profile()
+ {
+ $user = $this->user->getById($this->request->getIntegerParam('user_id'));
+
+ if (empty($user)) {
+ $this->notfound();
+ }
+
+ $this->response->html(
+ $this->helper->layout->app('user/profile', array(
+ 'title' => $user['name'] ?: $user['username'],
+ 'user' => $user,
+ )
+ ));
}
/**
@@ -64,13 +66,13 @@ class User extends Base
{
$is_remote = $this->request->getIntegerParam('remote') == 1 || (isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1);
- $this->response->html($this->template->layout($is_remote ? 'user/create_remote' : 'user/create_local', array(
+ $this->response->html($this->helper->layout->app($is_remote ? 'user/create_remote' : 'user/create_local', array(
'timezones' => $this->config->getTimezones(true),
'languages' => $this->config->getLanguages(true),
- 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
+ 'roles' => $this->role->getApplicationRoles(),
'projects' => $this->project->getList(),
'errors' => $errors,
- 'values' => $values,
+ 'values' => $values + array('role' => Role::APP_USER),
'title' => t('New user')
)));
}
@@ -83,7 +85,7 @@ class User extends Base
public function save()
{
$values = $this->request->getValues();
- list($valid, $errors) = $this->user->validateCreation($values);
+ list($valid, $errors) = $this->userValidator->validateCreation($values);
if ($valid) {
$project_id = empty($values['project_id']) ? 0 : $values['project_id'];
@@ -92,16 +94,16 @@ class User extends Base
$user_id = $this->user->create($values);
if ($user_id !== false) {
- $this->projectPermission->addMember($project_id, $user_id);
+ $this->projectUserRole->addUser($project_id, $user_id, Role::PROJECT_MEMBER);
if (! empty($values['notifications_enabled'])) {
- $this->userNotificationType->saveSelectedTypes($user_id, array(NotificationType::TYPE_EMAIL));
+ $this->userNotificationType->saveSelectedTypes($user_id, array(MailNotification::TYPE));
}
- $this->session->flash(t('User created successfully.'));
+ $this->flash->success(t('User created successfully.'));
$this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user_id)));
} else {
- $this->session->flashError(t('Unable to create your user.'));
+ $this->flash->failure(t('Unable to create your user.'));
$values['project_id'] = $project_id;
}
}
@@ -117,7 +119,7 @@ class User extends Base
public function show()
{
$user = $this->getUser();
- $this->response->html($this->layout('user/show', array(
+ $this->response->html($this->helper->layout->user('user/show', array(
'user' => $user,
'timezones' => $this->config->getTimezones(true),
'languages' => $this->config->getLanguages(true),
@@ -141,13 +143,27 @@ class User extends Base
->setQuery($this->subtaskTimeTracking->getUserQuery($user['id']))
->calculateOnlyIf($this->request->getStringParam('pagination') === 'subtasks');
- $this->response->html($this->layout('user/timesheet', array(
+ $this->response->html($this->helper->layout->user('user/timesheet', array(
'subtask_paginator' => $subtask_paginator,
'user' => $user,
)));
}
/**
+ * Display last password reset
+ *
+ * @access public
+ */
+ public function passwordReset()
+ {
+ $user = $this->getUser();
+ $this->response->html($this->helper->layout->user('user/password_reset', array(
+ 'tokens' => $this->passwordReset->getAll($user['id']),
+ 'user' => $user,
+ )));
+ }
+
+ /**
* Display last connections
*
* @access public
@@ -155,7 +171,7 @@ class User extends Base
public function last()
{
$user = $this->getUser();
- $this->response->html($this->layout('user/last', array(
+ $this->response->html($this->helper->layout->user('user/last', array(
'last_logins' => $this->lastLogin->getAll($user['id']),
'user' => $user,
)));
@@ -169,8 +185,8 @@ class User extends Base
public function sessions()
{
$user = $this->getUser();
- $this->response->html($this->layout('user/sessions', array(
- 'sessions' => $this->authentication->backend('rememberMe')->getAll($user['id']),
+ $this->response->html($this->helper->layout->user('user/sessions', array(
+ 'sessions' => $this->rememberMeSession->getAll($user['id']),
'user' => $user,
)));
}
@@ -184,8 +200,8 @@ class User extends Base
{
$this->checkCSRFParam();
$user = $this->getUser();
- $this->authentication->backend('rememberMe')->remove($this->request->getIntegerParam('id'));
- $this->response->redirect($this->helper->url->to('user', 'session', array('user_id' => $user['id'])));
+ $this->rememberMeSession->remove($this->request->getIntegerParam('id'));
+ $this->response->redirect($this->helper->url->to('user', 'sessions', array('user_id' => $user['id'])));
}
/**
@@ -200,12 +216,12 @@ class User extends Base
if ($this->request->isPost()) {
$values = $this->request->getValues();
$this->userNotification->saveSettings($user['id'], $values);
- $this->session->flash(t('User updated successfully.'));
+ $this->flash->success(t('User updated successfully.'));
$this->response->redirect($this->helper->url->to('user', 'notifications', array('user_id' => $user['id'])));
}
- $this->response->html($this->layout('user/notifications', array(
- 'projects' => $this->projectPermission->getMemberProjects($user['id']),
+ $this->response->html($this->helper->layout->user('user/notifications', array(
+ 'projects' => $this->projectUserRole->getProjectsByUser($user['id'], array(ProjectModel::ACTIVE)),
'notifications' => $this->userNotification->readSettings($user['id']),
'types' => $this->userNotificationType->getTypes(),
'filters' => $this->userNotificationFilter->getFilters(),
@@ -225,11 +241,11 @@ class User extends Base
if ($this->request->isPost()) {
$values = $this->request->getValues();
$this->userMetadata->save($user['id'], $values);
- $this->session->flash(t('User updated successfully.'));
+ $this->flash->success(t('User updated successfully.'));
$this->response->redirect($this->helper->url->to('user', 'integrations', array('user_id' => $user['id'])));
}
- $this->response->html($this->layout('user/integrations', array(
+ $this->response->html($this->helper->layout->user('user/integrations', array(
'user' => $user,
'values' => $this->userMetadata->getall($user['id']),
)));
@@ -243,7 +259,7 @@ class User extends Base
public function external()
{
$user = $this->getUser();
- $this->response->html($this->layout('user/external', array(
+ $this->response->html($this->helper->layout->user('user/external', array(
'last_logins' => $this->lastLogin->getAll($user['id']),
'user' => $user,
)));
@@ -263,15 +279,15 @@ class User extends Base
$this->checkCSRFParam();
if ($this->user->{$switch.'PublicAccess'}($user['id'])) {
- $this->session->flash(t('User updated successfully.'));
+ $this->flash->success(t('User updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update this user.'));
+ $this->flash->failure(t('Unable to update this user.'));
}
$this->response->redirect($this->helper->url->to('user', 'share', array('user_id' => $user['id'])));
}
- $this->response->html($this->layout('user/share', array(
+ $this->response->html($this->helper->layout->user('user/share', array(
'user' => $user,
'title' => t('Public access'),
)));
@@ -290,20 +306,20 @@ class User extends Base
if ($this->request->isPost()) {
$values = $this->request->getValues();
- list($valid, $errors) = $this->user->validatePasswordModification($values);
+ list($valid, $errors) = $this->userValidator->validatePasswordModification($values);
if ($valid) {
if ($this->user->update($values)) {
- $this->session->flash(t('Password modified successfully.'));
+ $this->flash->success(t('Password modified successfully.'));
} else {
- $this->session->flashError(t('Unable to change the password.'));
+ $this->flash->failure(t('Unable to change the password.'));
}
$this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user['id'])));
}
}
- $this->response->html($this->layout('user/password', array(
+ $this->response->html($this->helper->layout->user('user/password', array(
'values' => $values,
'errors' => $errors,
'user' => $user,
@@ -326,38 +342,32 @@ class User extends Base
if ($this->request->isPost()) {
$values = $this->request->getValues();
- if ($this->userSession->isAdmin()) {
- $values += array('is_admin' => 0, 'is_project_admin' => 0);
- } else {
- // Regular users can't be admin
- if (isset($values['is_admin'])) {
- unset($values['is_admin']);
- }
-
- if (isset($values['is_project_admin'])) {
- unset($values['is_project_admin']);
+ if (! $this->userSession->isAdmin()) {
+ if (isset($values['role'])) {
+ unset($values['role']);
}
}
- list($valid, $errors) = $this->user->validateModification($values);
+ list($valid, $errors) = $this->userValidator->validateModification($values);
if ($valid) {
if ($this->user->update($values)) {
- $this->session->flash(t('User updated successfully.'));
+ $this->flash->success(t('User updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update your user.'));
+ $this->flash->failure(t('Unable to update your user.'));
}
$this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user['id'])));
}
}
- $this->response->html($this->layout('user/edit', array(
+ $this->response->html($this->helper->layout->user('user/edit', array(
'values' => $values,
'errors' => $errors,
'user' => $user,
'timezones' => $this->config->getTimezones(true),
'languages' => $this->config->getLanguages(true),
+ 'roles' => $this->role->getApplicationRoles(),
)));
}
@@ -376,49 +386,23 @@ class User extends Base
if ($this->request->isPost()) {
$values = $this->request->getValues() + array('disable_login_form' => 0, 'is_ldap_user' => 0);
- list($valid, $errors) = $this->user->validateModification($values);
+ list($valid, $errors) = $this->userValidator->validateModification($values);
if ($valid) {
if ($this->user->update($values)) {
- $this->session->flash(t('User updated successfully.'));
+ $this->flash->success(t('User updated successfully.'));
} else {
- $this->session->flashError(t('Unable to update your user.'));
+ $this->flash->failure(t('Unable to update your user.'));
}
$this->response->redirect($this->helper->url->to('user', 'authentication', array('user_id' => $user['id'])));
}
}
- $this->response->html($this->layout('user/authentication', array(
+ $this->response->html($this->helper->layout->user('user/authentication', array(
'values' => $values,
'errors' => $errors,
'user' => $user,
)));
}
-
- /**
- * Remove a user
- *
- * @access public
- */
- public function remove()
- {
- $user = $this->getUser();
-
- if ($this->request->getStringParam('confirmation') === 'yes') {
- $this->checkCSRFParam();
-
- if ($this->user->remove($user['id'])) {
- $this->session->flash(t('User removed successfully.'));
- } else {
- $this->session->flashError(t('Unable to remove this user.'));
- }
-
- $this->response->redirect($this->helper->url->to('user', 'index'));
- }
-
- $this->response->html($this->layout('user/remove', array(
- 'user' => $user,
- )));
- }
}
diff --git a/app/Controller/UserHelper.php b/app/Controller/UserHelper.php
new file mode 100644
index 00000000..041ed2c8
--- /dev/null
+++ b/app/Controller/UserHelper.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * User Helper
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class UserHelper extends Base
+{
+ /**
+ * User autocompletion (Ajax)
+ *
+ * @access public
+ */
+ public function autocomplete()
+ {
+ $search = $this->request->getStringParam('term');
+ $users = $this->userFilterAutoCompleteFormatter->create($search)->filterByUsernameOrByName()->format();
+ $this->response->json($users);
+ }
+
+ /**
+ * User mention autocompletion (Ajax)
+ *
+ * @access public
+ */
+ public function mention()
+ {
+ $project_id = $this->request->getStringParam('project_id');
+ $query = $this->request->getStringParam('q');
+ $users = $this->projectPermission->findUsernames($project_id, $query);
+ $this->response->json($users);
+ }
+}
diff --git a/app/Controller/UserImport.php b/app/Controller/UserImport.php
index 32b9a865..debd69e5 100644
--- a/app/Controller/UserImport.php
+++ b/app/Controller/UserImport.php
@@ -18,7 +18,7 @@ class UserImport extends Base
*/
public function step1(array $values = array(), array $errors = array())
{
- $this->response->html($this->template->layout('user_import/step1', array(
+ $this->response->html($this->helper->layout->app('user_import/step1', array(
'values' => $values,
'errors' => $errors,
'max_size' => ini_get('upload_max_filesize'),
@@ -46,9 +46,9 @@ class UserImport extends Base
$csv->read($filename, array($this->userImport, 'import'));
if ($this->userImport->counter > 0) {
- $this->session->flash(t('%d user(s) have been imported successfully.', $this->userImport->counter));
+ $this->flash->success(t('%d user(s) have been imported successfully.', $this->userImport->counter));
} else {
- $this->session->flashError(t('Nothing have been imported!'));
+ $this->flash->failure(t('Nothing have been imported!'));
}
$this->response->redirect($this->helper->url->to('userImport', 'step1'));
diff --git a/app/Controller/UserStatus.php b/app/Controller/UserStatus.php
new file mode 100644
index 00000000..b8ee5c91
--- /dev/null
+++ b/app/Controller/UserStatus.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Kanboard\Controller;
+
+/**
+ * User Status Controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class UserStatus extends Base
+{
+ /**
+ * Confirm remove a user
+ *
+ * @access public
+ */
+ public function confirmRemove()
+ {
+ $user = $this->getUser();
+
+ $this->response->html($this->helper->layout->user('user_status/remove', array(
+ 'user' => $user,
+ )));
+ }
+
+ /**
+ * Remove a user
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $user = $this->getUser();
+ $this->checkCSRFParam();
+
+ if ($this->user->remove($user['id'])) {
+ $this->flash->success(t('User removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this user.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('user', 'index'));
+ }
+
+ /**
+ * Confirm enable a user
+ *
+ * @access public
+ */
+ public function confirmEnable()
+ {
+ $user = $this->getUser();
+
+ $this->response->html($this->helper->layout->user('user_status/enable', array(
+ 'user' => $user,
+ )));
+ }
+
+ /**
+ * Enable a user
+ *
+ * @access public
+ */
+ public function enable()
+ {
+ $user = $this->getUser();
+ $this->checkCSRFParam();
+
+ if ($this->user->enable($user['id'])) {
+ $this->flash->success(t('User activated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to enable this user.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('user', 'index'));
+ }
+
+ /**
+ * Confirm disable a user
+ *
+ * @access public
+ */
+ public function confirmDisable()
+ {
+ $user = $this->getUser();
+
+ $this->response->html($this->helper->layout->user('user_status/disable', array(
+ 'user' => $user,
+ )));
+ }
+
+ /**
+ * Disable a user
+ *
+ * @access public
+ */
+ public function disable()
+ {
+ $user = $this->getUser();
+ $this->checkCSRFParam();
+
+ if ($this->user->disable($user['id'])) {
+ $this->flash->success(t('User disabled successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to disable this user.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('user', 'index'));
+ }
+}
diff --git a/app/Controller/Webhook.php b/app/Controller/Webhook.php
index a7e9bde4..0eafe3e5 100644
--- a/app/Controller/Webhook.php
+++ b/app/Controller/Webhook.php
@@ -39,57 +39,4 @@ class Webhook extends Base
$this->response->text('FAILED');
}
-
- /**
- * Handle Github webhooks
- *
- * @access public
- */
- public function github()
- {
- $this->checkWebhookToken();
-
- $this->githubWebhook->setProjectId($this->request->getIntegerParam('project_id'));
-
- $result = $this->githubWebhook->parsePayload(
- $this->request->getHeader('X-Github-Event'),
- $this->request->getJson()
- );
-
- echo $result ? 'PARSED' : 'IGNORED';
- }
-
- /**
- * Handle Gitlab webhooks
- *
- * @access public
- */
- public function gitlab()
- {
- $this->checkWebhookToken();
-
- $this->gitlabWebhook->setProjectId($this->request->getIntegerParam('project_id'));
- $result = $this->gitlabWebhook->parsePayload($this->request->getJson());
-
- echo $result ? 'PARSED' : 'IGNORED';
- }
-
- /**
- * Handle Bitbucket webhooks
- *
- * @access public
- */
- public function bitbucket()
- {
- $this->checkWebhookToken();
-
- $this->bitbucketWebhook->setProjectId($this->request->getIntegerParam('project_id'));
-
- $result = $this->bitbucketWebhook->parsePayload(
- $this->request->getHeader('X-Event-Key'),
- $this->request->getJson()
- );
-
- echo $result ? 'PARSED' : 'IGNORED';
- }
}
diff --git a/app/Core/Action/ActionManager.php b/app/Core/Action/ActionManager.php
new file mode 100644
index 00000000..f1ea8abe
--- /dev/null
+++ b/app/Core/Action/ActionManager.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Kanboard\Core\Action;
+
+use RuntimeException;
+use Kanboard\Core\Base;
+use Kanboard\Action\Base as ActionBase;
+
+/**
+ * Action Manager
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class ActionManager extends Base
+{
+ /**
+ * List of automatic actions
+ *
+ * @access private
+ * @var array
+ */
+ private $actions = array();
+
+ /**
+ * Register a new automatic action
+ *
+ * @access public
+ * @param ActionBase $action
+ * @return ActionManager
+ */
+ public function register(ActionBase $action)
+ {
+ $this->actions[$action->getName()] = $action;
+ return $this;
+ }
+
+ /**
+ * Get automatic action instance
+ *
+ * @access public
+ * @param string $name Absolute class name with namespace
+ * @return ActionBase
+ */
+ public function getAction($name)
+ {
+ if (isset($this->actions[$name])) {
+ return $this->actions[$name];
+ }
+
+ throw new RuntimeException('Automatic Action Not Found: '.$name);
+ }
+
+ /**
+ * Get available automatic actions
+ *
+ * @access public
+ * @return array
+ */
+ public function getAvailableActions()
+ {
+ $actions = array();
+
+ foreach ($this->actions as $action) {
+ if (count($action->getEvents()) > 0) {
+ $actions[$action->getName()] = $action->getDescription();
+ }
+ }
+
+ asort($actions);
+
+ return $actions;
+ }
+
+ /**
+ * Get all available action parameters
+ *
+ * @access public
+ * @param array $actions
+ * @return array
+ */
+ public function getAvailableParameters(array $actions)
+ {
+ $params = array();
+
+ foreach ($actions as $action) {
+ $currentAction = $this->getAction($action['action_name']);
+ $params[$currentAction->getName()] = $currentAction->getActionRequiredParameters();
+ }
+
+ return $params;
+ }
+
+ /**
+ * Get list of compatible events for a given action
+ *
+ * @access public
+ * @param string $name
+ * @return array
+ */
+ public function getCompatibleEvents($name)
+ {
+ $events = array();
+ $actionEvents = $this->getAction($name)->getEvents();
+
+ foreach ($this->eventManager->getAll() as $event => $description) {
+ if (in_array($event, $actionEvents)) {
+ $events[$event] = $description;
+ }
+ }
+
+ return $events;
+ }
+
+ /**
+ * Bind automatic actions to events
+ *
+ * @access public
+ * @return ActionManager
+ */
+ public function attachEvents()
+ {
+ if ($this->userSession->isLogged()) {
+ $actions = $this->action->getAllByUser($this->userSession->getId());
+ } else {
+ $actions = $this->action->getAll();
+ }
+
+ foreach ($actions as $action) {
+ $listener = clone $this->getAction($action['action_name']);
+ $listener->setProjectId($action['project_id']);
+
+ foreach ($action['params'] as $param_name => $param_value) {
+ $listener->setParam($param_name, $param_value);
+ }
+
+ $this->dispatcher->addListener($action['event_name'], array($listener, 'execute'));
+ }
+
+ return $this;
+ }
+}
diff --git a/app/Core/Base.php b/app/Core/Base.php
index d402fb37..f1053114 100644
--- a/app/Core/Base.php
+++ b/app/Core/Base.php
@@ -5,49 +5,78 @@ namespace Kanboard\Core;
use Pimple\Container;
/**
- * Base class
+ * Base Class
*
* @package core
* @author Frederic Guillot
*
- * @property \Kanboard\Core\Helper $helper
+ * @property \Kanboard\Analytic\TaskDistributionAnalytic $taskDistributionAnalytic
+ * @property \Kanboard\Analytic\UserDistributionAnalytic $userDistributionAnalytic
+ * @property \Kanboard\Analytic\EstimatedTimeComparisonAnalytic $estimatedTimeComparisonAnalytic
+ * @property \Kanboard\Analytic\AverageLeadCycleTimeAnalytic $averageLeadCycleTimeAnalytic
+ * @property \Kanboard\Analytic\AverageTimeSpentColumnAnalytic $averageTimeSpentColumnAnalytic
+ * @property \Kanboard\Core\Action\ActionManager $actionManager
+ * @property \Kanboard\Core\ExternalLink\ExternalLinkManager $externalLinkManager
+ * @property \Kanboard\Core\Cache\MemoryCache $memoryCache
+ * @property \Kanboard\Core\Event\EventManager $eventManager
+ * @property \Kanboard\Core\Group\GroupManager $groupManager
+ * @property \Kanboard\Core\Http\Client $httpClient
+ * @property \Kanboard\Core\Http\OAuth2 $oauth
+ * @property \Kanboard\Core\Http\RememberMeCookie $rememberMeCookie
+ * @property \Kanboard\Core\Http\Request $request
+ * @property \Kanboard\Core\Http\Response $response
+ * @property \Kanboard\Core\Http\Router $router
+ * @property \Kanboard\Core\Http\Route $route
* @property \Kanboard\Core\Mail\Client $emailClient
- * @property \Kanboard\Core\HttpClient $httpClient
- * @property \Kanboard\Core\Paginator $paginator
- * @property \Kanboard\Core\Request $request
- * @property \Kanboard\Core\Session $session
- * @property \Kanboard\Core\Template $template
- * @property \Kanboard\Core\OAuth2 $oauth
- * @property \Kanboard\Core\Router $router
- * @property \Kanboard\Core\Lexer $lexer
* @property \Kanboard\Core\ObjectStorage\ObjectStorageInterface $objectStorage
- * @property \Kanboard\Core\Cache\Cache $memoryCache
* @property \Kanboard\Core\Plugin\Hook $hook
* @property \Kanboard\Core\Plugin\Loader $pluginLoader
- * @property \Kanboard\Integration\BitbucketWebhook $bitbucketWebhook
- * @property \Kanboard\Integration\GithubWebhook $githubWebhook
- * @property \Kanboard\Integration\GitlabWebhook $gitlabWebhook
+ * @property \Kanboard\Core\Security\AccessMap $projectAccessMap
+ * @property \Kanboard\Core\Security\AuthenticationManager $authenticationManager
+ * @property \Kanboard\Core\Security\AccessMap $applicationAccessMap
+ * @property \Kanboard\Core\Security\AccessMap $projectAccessMap
+ * @property \Kanboard\Core\Security\Authorization $applicationAuthorization
+ * @property \Kanboard\Core\Security\Authorization $projectAuthorization
+ * @property \Kanboard\Core\Security\Role $role
+ * @property \Kanboard\Core\Security\Token $token
+ * @property \Kanboard\Core\Session\FlashMessage $flash
+ * @property \Kanboard\Core\Session\SessionManager $sessionManager
+ * @property \Kanboard\Core\Session\SessionStorage $sessionStorage
+ * @property \Kanboard\Core\User\GroupSync $groupSync
+ * @property \Kanboard\Core\User\UserProfile $userProfile
+ * @property \Kanboard\Core\User\UserSync $userSync
+ * @property \Kanboard\Core\User\UserSession $userSession
+ * @property \Kanboard\Core\DateParser $dateParser
+ * @property \Kanboard\Core\Helper $helper
+ * @property \Kanboard\Core\Lexer $lexer
+ * @property \Kanboard\Core\Paginator $paginator
+ * @property \Kanboard\Core\Template $template
* @property \Kanboard\Formatter\ProjectGanttFormatter $projectGanttFormatter
* @property \Kanboard\Formatter\TaskFilterGanttFormatter $taskFilterGanttFormatter
* @property \Kanboard\Formatter\TaskFilterAutoCompleteFormatter $taskFilterAutoCompleteFormatter
* @property \Kanboard\Formatter\TaskFilterCalendarFormatter $taskFilterCalendarFormatter
* @property \Kanboard\Formatter\TaskFilterICalendarFormatter $taskFilterICalendarFormatter
- * @property \Kanboard\Model\Acl $acl
+ * @property \Kanboard\Formatter\UserFilterAutoCompleteFormatter $userFilterAutoCompleteFormatter
+ * @property \Kanboard\Formatter\GroupAutoCompleteFormatter $groupAutoCompleteFormatter
* @property \Kanboard\Model\Action $action
- * @property \Kanboard\Model\Authentication $authentication
+ * @property \Kanboard\Model\ActionParameter $actionParameter
* @property \Kanboard\Model\Board $board
* @property \Kanboard\Model\Category $category
* @property \Kanboard\Model\Color $color
+ * @property \Kanboard\Model\Column $column
* @property \Kanboard\Model\Comment $comment
* @property \Kanboard\Model\Config $config
* @property \Kanboard\Model\Currency $currency
* @property \Kanboard\Model\CustomFilter $customFilter
- * @property \Kanboard\Model\DateParser $dateParser
- * @property \Kanboard\Model\File $file
+ * @property \Kanboard\Model\TaskFile $taskFile
+ * @property \Kanboard\Model\ProjectFile $projectFile
+ * @property \Kanboard\Model\Group $group
+ * @property \Kanboard\Model\GroupMember $groupMember
* @property \Kanboard\Model\LastLogin $lastLogin
* @property \Kanboard\Model\Link $link
* @property \Kanboard\Model\Notification $notification
* @property \Kanboard\Model\OverdueNotification $overdueNotification
+ * @property \Kanboard\Model\PasswordReset $passwordReset
* @property \Kanboard\Model\Project $project
* @property \Kanboard\Model\ProjectActivity $projectActivity
* @property \Kanboard\Model\ProjectAnalytic $projectAnalytic
@@ -56,8 +85,12 @@ use Pimple\Container;
* @property \Kanboard\Model\ProjectDailyStats $projectDailyStats
* @property \Kanboard\Model\ProjectMetadata $projectMetadata
* @property \Kanboard\Model\ProjectPermission $projectPermission
+ * @property \Kanboard\Model\ProjectUserRole $projectUserRole
+ * @property \Kanboard\Model\projectUserRoleFilter $projectUserRoleFilter
+ * @property \Kanboard\Model\ProjectGroupRole $projectGroupRole
* @property \Kanboard\Model\ProjectNotification $projectNotification
* @property \Kanboard\Model\ProjectNotificationType $projectNotificationType
+ * @property \Kanboard\Model\RememberMeSession $rememberMeSession
* @property \Kanboard\Model\Subtask $subtask
* @property \Kanboard\Model\SubtaskExport $subtaskExport
* @property \Kanboard\Model\SubtaskTimeTracking $subtaskTimeTracking
@@ -67,6 +100,7 @@ use Pimple\Container;
* @property \Kanboard\Model\TaskCreation $taskCreation
* @property \Kanboard\Model\TaskDuplication $taskDuplication
* @property \Kanboard\Model\TaskExport $taskExport
+ * @property \Kanboard\Model\TaskExternalLink $taskExternalLink
* @property \Kanboard\Model\TaskImport $taskImport
* @property \Kanboard\Model\TaskFinder $taskFinder
* @property \Kanboard\Model\TaskFilter $taskFilter
@@ -75,21 +109,39 @@ use Pimple\Container;
* @property \Kanboard\Model\TaskPermission $taskPermission
* @property \Kanboard\Model\TaskPosition $taskPosition
* @property \Kanboard\Model\TaskStatus $taskStatus
- * @property \Kanboard\Model\TaskValidator $taskValidator
* @property \Kanboard\Model\TaskMetadata $taskMetadata
* @property \Kanboard\Model\Transition $transition
* @property \Kanboard\Model\User $user
* @property \Kanboard\Model\UserImport $userImport
+ * @property \Kanboard\Model\UserLocking $userLocking
+ * @property \Kanboard\Model\UserMention $userMention
* @property \Kanboard\Model\UserNotification $userNotification
* @property \Kanboard\Model\UserNotificationType $userNotificationType
* @property \Kanboard\Model\UserNotificationFilter $userNotificationFilter
* @property \Kanboard\Model\UserUnreadNotification $userUnreadNotification
- * @property \Kanboard\Model\UserSession $userSession
* @property \Kanboard\Model\UserMetadata $userMetadata
* @property \Kanboard\Model\Webhook $webhook
+ * @property \Kanboard\Validator\ActionValidator $actionValidator
+ * @property \Kanboard\Validator\AuthValidator $authValidator
+ * @property \Kanboard\Validator\ColumnValidator $columnValidator
+ * @property \Kanboard\Validator\CategoryValidator $categoryValidator
+ * @property \Kanboard\Validator\ColumnValidator $columnValidator
+ * @property \Kanboard\Validator\CommentValidator $commentValidator
+ * @property \Kanboard\Validator\CurrencyValidator $currencyValidator
+ * @property \Kanboard\Validator\CustomFilterValidator $customFilterValidator
+ * @property \Kanboard\Validator\GroupValidator $groupValidator
+ * @property \Kanboard\Validator\LinkValidator $linkValidator
+ * @property \Kanboard\Validator\PasswordResetValidator $passwordResetValidator
+ * @property \Kanboard\Validator\ProjectValidator $projectValidator
+ * @property \Kanboard\Validator\SubtaskValidator $subtaskValidator
+ * @property \Kanboard\Validator\SwimlaneValidator $swimlaneValidator
+ * @property \Kanboard\Validator\TaskLinkValidator $taskLinkValidator
+ * @property \Kanboard\Validator\TaskExternalLinkValidator $taskExternalLinkValidator
+ * @property \Kanboard\Validator\TaskValidator $taskValidator
+ * @property \Kanboard\Validator\UserValidator $userValidator
* @property \Psr\Log\LoggerInterface $logger
- * @property \League\HTMLToMarkdown\HtmlConverter $htmlConverter
* @property \PicoDb\Database $db
+ * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher
*/
abstract class Base
{
diff --git a/app/Core/Cache/MemoryCache.php b/app/Core/Cache/MemoryCache.php
index c4fb7ca4..39e3947b 100644
--- a/app/Core/Cache/MemoryCache.php
+++ b/app/Core/Cache/MemoryCache.php
@@ -23,7 +23,7 @@ class MemoryCache extends Base implements CacheInterface
*
* @access public
* @param string $key
- * @param string $value
+ * @param mixed $value
*/
public function set($key, $value)
{
diff --git a/app/Core/Csv.php b/app/Core/Csv.php
index bec400ed..e45af24c 100644
--- a/app/Core/Csv.php
+++ b/app/Core/Csv.php
@@ -93,8 +93,7 @@ class Csv
{
if (! empty($value)) {
$value = trim(strtolower($value));
- return $value === '1' || $value{0}
- === 't' ? 1 : 0;
+ return $value === '1' || $value{0} === 't' || $value{0} === 'y' ? 1 : 0;
}
return 0;
@@ -164,10 +163,14 @@ class Csv
*/
public function write($filename, array $rows)
{
- $file = new SplFileObject($filename, 'w');
+ $fp = fopen($filename, 'w');
- foreach ($rows as $row) {
- $file->fputcsv($row, $this->delimiter, $this->enclosure);
+ if (is_resource($fp)) {
+ foreach ($rows as $row) {
+ fputcsv($fp, $row, $this->delimiter, $this->enclosure);
+ }
+
+ fclose($fp);
}
return $this;
diff --git a/app/Core/DateParser.php b/app/Core/DateParser.php
index 6577af0f..20e79ff9 100644
--- a/app/Core/DateParser.php
+++ b/app/Core/DateParser.php
@@ -13,67 +13,91 @@ use DateTime;
class DateParser extends Base
{
/**
- * Return true if the date is within the date range
+ * List of time formats
*
* @access public
- * @param DateTime $date
- * @param DateTime $start
- * @param DateTime $end
- * @return boolean
+ * @return string[]
*/
- public function withinDateRange(DateTime $date, DateTime $start, DateTime $end)
+ public function getTimeFormats()
{
- return $date >= $start && $date <= $end;
+ return array(
+ 'H:i',
+ 'g:i a',
+ );
}
/**
- * Get the total number of hours between 2 datetime objects
- * Minutes are rounded to the nearest quarter
+ * List of date formats
*
* @access public
- * @param DateTime $d1
- * @param DateTime $d2
- * @return float
+ * @param boolean $iso
+ * @return string[]
*/
- public function getHours(DateTime $d1, DateTime $d2)
+ public function getDateFormats($iso = false)
{
- $seconds = $this->getRoundedSeconds(abs($d1->getTimestamp() - $d2->getTimestamp()));
- return round($seconds / 3600, 2);
+ $iso_formats = array(
+ 'Y-m-d',
+ 'Y_m_d',
+ );
+
+ $user_formats = array(
+ 'm/d/Y',
+ 'd/m/Y',
+ 'Y/m/d',
+ 'd.m.Y',
+ );
+
+ return $iso ? array_merge($iso_formats, $user_formats) : $user_formats;
}
/**
- * Round the timestamp to the nearest quarter
+ * List of datetime formats
*
* @access public
- * @param integer $seconds Timestamp
- * @return integer
+ * @param boolean $iso
+ * @return string[]
*/
- public function getRoundedSeconds($seconds)
+ public function getDateTimeFormats($iso = false)
{
- return (int) round($seconds / (15 * 60)) * (15 * 60);
+ $formats = array();
+
+ foreach ($this->getDateFormats($iso) as $date) {
+ foreach ($this->getTimeFormats() as $time) {
+ $formats[] = $date.' '.$time;
+ }
+ }
+
+ return $formats;
}
/**
- * Return a timestamp if the given date format is correct otherwise return 0
+ * List of all date formats
*
* @access public
- * @param string $value Date to parse
- * @param string $format Date format
- * @return integer
+ * @param boolean $iso
+ * @return string[]
*/
- public function getValidDate($value, $format)
+ public function getAllDateFormats($iso = false)
{
- $date = DateTime::createFromFormat($format, $value);
+ return array_merge($this->getDateFormats($iso), $this->getDateTimeFormats($iso));
+ }
- if ($date !== false) {
- $errors = DateTime::getLastErrors();
- if ($errors['error_count'] === 0 && $errors['warning_count'] === 0) {
- $timestamp = $date->getTimestamp();
- return $timestamp > 0 ? $timestamp : 0;
- }
+ /**
+ * Get available formats (visible in settings)
+ *
+ * @access public
+ * @param array $formats
+ * @return array
+ */
+ public function getAvailableFormats(array $formats)
+ {
+ $values = array();
+
+ foreach ($formats as $format) {
+ $values[$format] = date($format);
}
- return 0;
+ return $values;
}
/**
@@ -85,7 +109,11 @@ class DateParser extends Base
*/
public function getTimestamp($value)
{
- foreach ($this->getAllFormats() as $format) {
+ if (ctype_digit($value)) {
+ return (int) $value;
+ }
+
+ foreach ($this->getAllDateFormats(true) as $format) {
$timestamp = $this->getValidDate($value, $format);
if ($timestamp !== 0) {
@@ -97,104 +125,103 @@ class DateParser extends Base
}
/**
- * Get ISO8601 date from user input
+ * Return a timestamp if the given date format is correct otherwise return 0
*
- * @access public
- * @param string $value Date to parse
- * @return string
+ * @access private
+ * @param string $value Date to parse
+ * @param string $format Date format
+ * @return integer
*/
- public function getIsoDate($value)
+ private function getValidDate($value, $format)
{
- return date('Y-m-d', ctype_digit($value) ? $value : $this->getTimestamp($value));
+ $date = DateTime::createFromFormat($format, $value);
+
+ if ($date !== false) {
+ $errors = DateTime::getLastErrors();
+ if ($errors['error_count'] === 0 && $errors['warning_count'] === 0) {
+ $timestamp = $date->getTimestamp();
+ return $timestamp > 0 ? $timestamp : 0;
+ }
+ }
+
+ return 0;
}
/**
- * Get all combinations of date/time formats
+ * Return true if the date is within the date range
*
* @access public
- * @return string[]
+ * @param DateTime $date
+ * @param DateTime $start
+ * @param DateTime $end
+ * @return boolean
*/
- public function getAllFormats()
+ public function withinDateRange(DateTime $date, DateTime $start, DateTime $end)
{
- $formats = array();
-
- foreach ($this->getDateFormats() as $date) {
- foreach ($this->getTimeFormats() as $time) {
- $formats[] = $date.' '.$time;
- }
- }
-
- return array_merge($formats, $this->getDateFormats());
+ return $date >= $start && $date <= $end;
}
/**
- * Return the list of supported date formats (for the parser)
+ * Get the total number of hours between 2 datetime objects
+ * Minutes are rounded to the nearest quarter
*
* @access public
- * @return string[]
+ * @param DateTime $d1
+ * @param DateTime $d2
+ * @return float
*/
- public function getDateFormats()
+ public function getHours(DateTime $d1, DateTime $d2)
{
- return array(
- $this->config->get('application_date_format', 'm/d/Y'),
- 'Y-m-d',
- 'Y_m_d',
- );
+ $seconds = $this->getRoundedSeconds(abs($d1->getTimestamp() - $d2->getTimestamp()));
+ return round($seconds / 3600, 2);
}
/**
- * Return the list of supported time formats (for the parser)
+ * Round the timestamp to the nearest quarter
*
* @access public
- * @return string[]
+ * @param integer $seconds Timestamp
+ * @return integer
*/
- public function getTimeFormats()
+ public function getRoundedSeconds($seconds)
{
- return array(
- 'H:i',
- 'g:i A',
- 'g:iA',
- );
+ return (int) round($seconds / (15 * 60)) * (15 * 60);
}
/**
- * Return the list of available date formats (for the config page)
+ * Get ISO-8601 date from user input
*
* @access public
- * @return array
+ * @param string $value Date to parse
+ * @return string
*/
- public function getAvailableFormats()
+ public function getIsoDate($value)
{
- return array(
- 'm/d/Y' => date('m/d/Y'),
- 'd/m/Y' => date('d/m/Y'),
- 'Y/m/d' => date('Y/m/d'),
- 'd.m.Y' => date('d.m.Y'),
- );
+ return date('Y-m-d', $this->getTimestamp($value));
}
/**
- * Remove the time from a timestamp
+ * Get a timetstamp from an ISO date format
*
* @access public
- * @param integer $timestamp Timestamp
+ * @param string $value
* @return integer
*/
- public function removeTimeFromTimestamp($timestamp)
+ public function getTimestampFromIsoFormat($value)
{
- return mktime(0, 0, 0, date('m', $timestamp), date('d', $timestamp), date('Y', $timestamp));
+ return $this->removeTimeFromTimestamp(ctype_digit($value) ? $value : strtotime($value));
}
/**
- * Get a timetstamp from an ISO date format
+ * Remove the time from a timestamp
*
* @access public
- * @param string $date
+ * @param integer $timestamp
* @return integer
*/
- public function getTimestampFromIsoFormat($date)
+ public function removeTimeFromTimestamp($timestamp)
{
- return $this->removeTimeFromTimestamp(ctype_digit($date) ? $date : strtotime($date));
+ return mktime(0, 0, 0, date('m', $timestamp), date('d', $timestamp), date('Y', $timestamp));
}
/**
@@ -204,13 +231,10 @@ class DateParser extends Base
* @param array $values Database values
* @param string[] $fields Date fields
* @param string $format Date format
+ * @return array
*/
- public function format(array &$values, array $fields, $format = '')
+ public function format(array $values, array $fields, $format)
{
- if ($format === '') {
- $format = $this->config->get('application_date_format');
- }
-
foreach ($fields as $field) {
if (! empty($values[$field])) {
$values[$field] = date($format, $values[$field]);
@@ -218,23 +242,28 @@ class DateParser extends Base
$values[$field] = '';
}
}
+
+ return $values;
}
/**
- * Convert date (form input data)
+ * Convert date to timestamp
*
* @access public
* @param array $values Database values
* @param string[] $fields Date fields
* @param boolean $keep_time Keep time or not
+ * @return array
*/
- public function convert(array &$values, array $fields, $keep_time = false)
+ public function convert(array $values, array $fields, $keep_time = false)
{
foreach ($fields as $field) {
- if (! empty($values[$field]) && ! is_numeric($values[$field])) {
+ if (! empty($values[$field])) {
$timestamp = $this->getTimestamp($values[$field]);
$values[$field] = $keep_time ? $timestamp : $this->removeTimeFromTimestamp($timestamp);
}
}
+
+ return $values;
}
}
diff --git a/app/Core/Event/EventManager.php b/app/Core/Event/EventManager.php
new file mode 100644
index 00000000..162d23e8
--- /dev/null
+++ b/app/Core/Event/EventManager.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Kanboard\Core\Event;
+
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskLink;
+
+/**
+ * Event Manager
+ *
+ * @package event
+ * @author Frederic Guillot
+ */
+class EventManager
+{
+ /**
+ * Extended events
+ *
+ * @access private
+ * @var array
+ */
+ private $events = array();
+
+ /**
+ * Add new event
+ *
+ * @access public
+ * @param string $event
+ * @param string $description
+ * @return EventManager
+ */
+ public function register($event, $description)
+ {
+ $this->events[$event] = $description;
+ return $this;
+ }
+
+ /**
+ * Get the list of events and description that can be used from the user interface
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ $events = array(
+ TaskLink::EVENT_CREATE_UPDATE => t('Task link creation or modification'),
+ Task::EVENT_MOVE_COLUMN => t('Move a task to another column'),
+ Task::EVENT_UPDATE => t('Task modification'),
+ Task::EVENT_CREATE => t('Task creation'),
+ Task::EVENT_OPEN => t('Reopen a task'),
+ Task::EVENT_CLOSE => t('Closing a task'),
+ Task::EVENT_CREATE_UPDATE => t('Task creation or modification'),
+ Task::EVENT_ASSIGNEE_CHANGE => t('Task assignee change'),
+ Task::EVENT_DAILY_CRONJOB => t('Daily background job for tasks'),
+ );
+
+ $events = array_merge($events, $this->events);
+ asort($events);
+
+ return $events;
+ }
+}
diff --git a/app/Core/ExternalLink/ExternalLinkInterface.php b/app/Core/ExternalLink/ExternalLinkInterface.php
new file mode 100644
index 00000000..2dbc0a19
--- /dev/null
+++ b/app/Core/ExternalLink/ExternalLinkInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Kanboard\Core\ExternalLink;
+
+/**
+ * External Link Interface
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+interface ExternalLinkInterface
+{
+ /**
+ * Get link title
+ *
+ * @access public
+ * @return string
+ */
+ public function getTitle();
+
+ /**
+ * Get link URL
+ *
+ * @access public
+ * @return string
+ */
+ public function getUrl();
+
+ /**
+ * Set link URL
+ *
+ * @access public
+ * @param string $url
+ */
+ public function setUrl($url);
+}
diff --git a/app/Core/ExternalLink/ExternalLinkManager.php b/app/Core/ExternalLink/ExternalLinkManager.php
new file mode 100644
index 00000000..1fa423c2
--- /dev/null
+++ b/app/Core/ExternalLink/ExternalLinkManager.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Kanboard\Core\ExternalLink;
+
+use Kanboard\Core\Base;
+
+/**
+ * External Link Manager
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+class ExternalLinkManager extends Base
+{
+ /**
+ * Automatic type value
+ *
+ * @var string
+ */
+ const TYPE_AUTO = 'auto';
+
+ /**
+ * Registered providers
+ *
+ * @access private
+ * @var array
+ */
+ private $providers = array();
+
+ /**
+ * Type chosen by the user
+ *
+ * @access private
+ * @var string
+ */
+ private $userInputType = '';
+
+ /**
+ * Text entered by the user
+ *
+ * @access private
+ * @var string
+ */
+ private $userInputText = '';
+
+ /**
+ * Register a new provider
+ *
+ * Providers are registered in a LIFO queue
+ *
+ * @access public
+ * @param ExternalLinkProviderInterface $provider
+ * @return ExternalLinkManager
+ */
+ public function register(ExternalLinkProviderInterface $provider)
+ {
+ array_unshift($this->providers, $provider);
+ return $this;
+ }
+
+ /**
+ * Get provider
+ *
+ * @access public
+ * @param string $type
+ * @throws ExternalLinkProviderNotFound
+ * @return ExternalLinkProviderInterface
+ */
+ public function getProvider($type)
+ {
+ foreach ($this->providers as $provider) {
+ if ($provider->getType() === $type) {
+ return $provider;
+ }
+ }
+
+ throw new ExternalLinkProviderNotFound('Unable to find link provider: '.$type);
+ }
+
+ /**
+ * Get link types
+ *
+ * @access public
+ * @return array
+ */
+ public function getTypes()
+ {
+ $types = array();
+
+ foreach ($this->providers as $provider) {
+ $types[$provider->getType()] = $provider->getName();
+ }
+
+ asort($types);
+
+ return array(self::TYPE_AUTO => t('Auto')) + $types;
+ }
+
+ /**
+ * Get dependency label from a provider
+ *
+ * @access public
+ * @param string $type
+ * @param string $dependency
+ * @return string
+ */
+ public function getDependencyLabel($type, $dependency)
+ {
+ $provider = $this->getProvider($type);
+ $dependencies = $provider->getDependencies();
+ return isset($dependencies[$dependency]) ? $dependencies[$dependency] : $dependency;
+ }
+
+ /**
+ * Find a provider that match
+ *
+ * @access public
+ * @throws ExternalLinkProviderNotFound
+ * @return ExternalLinkProviderInterface
+ */
+ public function find()
+ {
+ if ($this->userInputType === self::TYPE_AUTO) {
+ $provider = $this->findProvider();
+ } else {
+ $provider = $this->getProvider($this->userInputType);
+ $provider->setUserTextInput($this->userInputText);
+
+ if (! $provider->match()) {
+ throw new ExternalLinkProviderNotFound('Unable to parse URL with selected provider');
+ }
+ }
+
+ if ($provider === null) {
+ throw new ExternalLinkProviderNotFound('Unable to find link information from provided information');
+ }
+
+ return $provider;
+ }
+
+ /**
+ * Set form values
+ *
+ * @access public
+ * @param array $values
+ * @return ExternalLinkManager
+ */
+ public function setUserInput(array $values)
+ {
+ $this->userInputType = empty($values['type']) ? self::TYPE_AUTO : $values['type'];
+ $this->userInputText = empty($values['text']) ? '' : trim($values['text']);
+ return $this;
+ }
+
+ /**
+ * Find a provider that user input
+ *
+ * @access private
+ * @return ExternalLinkProviderInterface
+ */
+ private function findProvider()
+ {
+ foreach ($this->providers as $provider) {
+ $provider->setUserTextInput($this->userInputText);
+
+ if ($provider->match()) {
+ return $provider;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/Core/ExternalLink/ExternalLinkProviderInterface.php b/app/Core/ExternalLink/ExternalLinkProviderInterface.php
new file mode 100644
index 00000000..c908e1eb
--- /dev/null
+++ b/app/Core/ExternalLink/ExternalLinkProviderInterface.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Kanboard\Core\ExternalLink;
+
+/**
+ * External Link Provider Interface
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+interface ExternalLinkProviderInterface
+{
+ /**
+ * Get provider name (label)
+ *
+ * @access public
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Get link type (will be saved in the database)
+ *
+ * @access public
+ * @return string
+ */
+ public function getType();
+
+ /**
+ * Get a dictionary of supported dependency types by the provider
+ *
+ * Example:
+ *
+ * [
+ * 'related' => t('Related'),
+ * 'child' => t('Child'),
+ * 'parent' => t('Parent'),
+ * 'self' => t('Self'),
+ * ]
+ *
+ * The dictionary key is saved in the database.
+ *
+ * @access public
+ * @return array
+ */
+ public function getDependencies();
+
+ /**
+ * Set text entered by the user
+ *
+ * @access public
+ * @param string $input
+ */
+ public function setUserTextInput($input);
+
+ /**
+ * Return true if the provider can parse correctly the user input
+ *
+ * @access public
+ * @return boolean
+ */
+ public function match();
+
+ /**
+ * Get the link found with the properties
+ *
+ * @access public
+ * @return ExternalLinkInterface
+ */
+ public function getLink();
+}
diff --git a/app/Core/ExternalLink/ExternalLinkProviderNotFound.php b/app/Core/ExternalLink/ExternalLinkProviderNotFound.php
new file mode 100644
index 00000000..4fd05202
--- /dev/null
+++ b/app/Core/ExternalLink/ExternalLinkProviderNotFound.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Kanboard\Core\ExternalLink;
+
+use Exception;
+
+/**
+ * External Link Provider Not Found Exception
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+class ExternalLinkProviderNotFound extends Exception
+{
+}
diff --git a/app/Core/Group/GroupBackendProviderInterface.php b/app/Core/Group/GroupBackendProviderInterface.php
new file mode 100644
index 00000000..74c5cb03
--- /dev/null
+++ b/app/Core/Group/GroupBackendProviderInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Kanboard\Core\Group;
+
+/**
+ * Group Backend Provider Interface
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+interface GroupBackendProviderInterface
+{
+ /**
+ * Find a group from a search query
+ *
+ * @access public
+ * @param string $input
+ * @return GroupProviderInterface[]
+ */
+ public function find($input);
+}
diff --git a/app/Core/Group/GroupManager.php b/app/Core/Group/GroupManager.php
new file mode 100644
index 00000000..48b6c4f8
--- /dev/null
+++ b/app/Core/Group/GroupManager.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Kanboard\Core\Group;
+
+/**
+ * Group Manager
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+class GroupManager
+{
+ /**
+ * List of backend providers
+ *
+ * @access private
+ * @var array
+ */
+ private $providers = array();
+
+ /**
+ * Register a new group backend provider
+ *
+ * @access public
+ * @param GroupBackendProviderInterface $provider
+ * @return GroupManager
+ */
+ public function register(GroupBackendProviderInterface $provider)
+ {
+ $this->providers[] = $provider;
+ return $this;
+ }
+
+ /**
+ * Find a group from a search query
+ *
+ * @access public
+ * @param string $input
+ * @return GroupProviderInterface[]
+ */
+ public function find($input)
+ {
+ $groups = array();
+
+ foreach ($this->providers as $provider) {
+ $groups = array_merge($groups, $provider->find($input));
+ }
+
+ return $this->removeDuplicates($groups);
+ }
+
+ /**
+ * Remove duplicated groups
+ *
+ * @access private
+ * @param array $groups
+ * @return GroupProviderInterface[]
+ */
+ private function removeDuplicates(array $groups)
+ {
+ $result = array();
+
+ foreach ($groups as $group) {
+ if (! isset($result[$group->getName()])) {
+ $result[$group->getName()] = $group;
+ }
+ }
+
+ return array_values($result);
+ }
+}
diff --git a/app/Core/Group/GroupProviderInterface.php b/app/Core/Group/GroupProviderInterface.php
new file mode 100644
index 00000000..4c7c16ec
--- /dev/null
+++ b/app/Core/Group/GroupProviderInterface.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Kanboard\Core\Group;
+
+/**
+ * Group Provider Interface
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+interface GroupProviderInterface
+{
+ /**
+ * Get internal id
+ *
+ * You must return 0 if the group come from an external backend
+ *
+ * @access public
+ * @return integer
+ */
+ public function getInternalId();
+
+ /**
+ * Get external id
+ *
+ * You must return a unique id if the group come from an external provider
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId();
+
+ /**
+ * Get group name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName();
+}
diff --git a/app/Core/Helper.php b/app/Core/Helper.php
index 5edaa3f0..bf71769f 100644
--- a/app/Core/Helper.php
+++ b/app/Core/Helper.php
@@ -20,6 +20,7 @@ use Pimple\Container;
* @property \Helper\Text $text
* @property \Helper\Url $url
* @property \Helper\User $user
+ * @property \Helper\Layout $layout
*/
class Helper
{
diff --git a/app/Core/HttpClient.php b/app/Core/Http/Client.php
index 7f4ea47a..12b0a1cb 100644
--- a/app/Core/HttpClient.php
+++ b/app/Core/Http/Client.php
@@ -1,14 +1,16 @@
<?php
-namespace Kanboard\Core;
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
/**
* HTTP client
*
- * @package core
+ * @package http
* @author Frederic Guillot
*/
-class HttpClient extends Base
+class Client extends Base
{
/**
* HTTP connection timeout in seconds
@@ -32,6 +34,19 @@ class HttpClient extends Base
const HTTP_USER_AGENT = 'Kanboard';
/**
+ * Send a GET HTTP request
+ *
+ * @access public
+ * @param string $url
+ * @param string[] $headers
+ * @return string
+ */
+ public function get($url, array $headers = array())
+ {
+ return $this->doRequest('GET', $url, '', $headers);
+ }
+
+ /**
* Send a GET HTTP request and parse JSON response
*
* @access public
@@ -99,6 +114,36 @@ class HttpClient extends Base
return '';
}
+ $stream = @fopen(trim($url), 'r', false, stream_context_create($this->getContext($method, $content, $headers)));
+ $response = '';
+
+ if (is_resource($stream)) {
+ $response = stream_get_contents($stream);
+ } else {
+ $this->logger->error('HttpClient: request failed');
+ }
+
+ if (DEBUG) {
+ $this->logger->debug('HttpClient: url='.$url);
+ $this->logger->debug('HttpClient: payload='.$content);
+ $this->logger->debug('HttpClient: metadata='.var_export(@stream_get_meta_data($stream), true));
+ $this->logger->debug('HttpClient: response='.$response);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get stream context
+ *
+ * @access private
+ * @param string $method
+ * @param string $content
+ * @param string[] $headers
+ * @return array
+ */
+ private function getContext($method, $content, array $headers)
+ {
$default_headers = array(
'User-Agent: '.self::HTTP_USER_AGENT,
'Connection: close',
@@ -126,22 +171,6 @@ class HttpClient extends Base
$context['http']['request_fulluri'] = true;
}
- $stream = @fopen(trim($url), 'r', false, stream_context_create($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('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;
+ return $context;
}
}
diff --git a/app/Core/OAuth2.php b/app/Core/Http/OAuth2.php
index a5bbba1a..6fa1fb0a 100644
--- a/app/Core/OAuth2.php
+++ b/app/Core/Http/OAuth2.php
@@ -1,11 +1,13 @@
<?php
-namespace Kanboard\Core;
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
/**
- * OAuth2 client
+ * OAuth2 Client
*
- * @package core
+ * @package http
* @author Frederic Guillot
*/
class OAuth2 extends Base
diff --git a/app/Core/Http/RememberMeCookie.php b/app/Core/Http/RememberMeCookie.php
new file mode 100644
index 00000000..a32b35f3
--- /dev/null
+++ b/app/Core/Http/RememberMeCookie.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+
+/**
+ * Remember Me Cookie
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class RememberMeCookie extends Base
+{
+ /**
+ * Cookie name
+ *
+ * @var string
+ */
+ const COOKIE_NAME = 'KB_RM';
+
+ /**
+ * Encode the cookie
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @return string
+ */
+ public function encode($token, $sequence)
+ {
+ return implode('|', array($token, $sequence));
+ }
+
+ /**
+ * Decode the value of a cookie
+ *
+ * @access public
+ * @param string $value Raw cookie data
+ * @return array
+ */
+ public function decode($value)
+ {
+ list($token, $sequence) = explode('|', $value);
+
+ return array(
+ 'token' => $token,
+ 'sequence' => $sequence,
+ );
+ }
+
+ /**
+ * Return true if the current user has a RememberMe cookie
+ *
+ * @access public
+ * @return bool
+ */
+ public function hasCookie()
+ {
+ return $this->request->getCookie(self::COOKIE_NAME) !== '';
+ }
+
+ /**
+ * Write and encode the cookie
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @param string $expiration Cookie expiration
+ * @return boolean
+ */
+ public function write($token, $sequence, $expiration)
+ {
+ return setcookie(
+ self::COOKIE_NAME,
+ $this->encode($token, $sequence),
+ $expiration,
+ $this->helper->url->dir(),
+ null,
+ $this->request->isHTTPS(),
+ true
+ );
+ }
+
+ /**
+ * Read and decode the cookie
+ *
+ * @access public
+ * @return mixed
+ */
+ public function read()
+ {
+ $cookie = $this->request->getCookie(self::COOKIE_NAME);
+
+ if (empty($cookie)) {
+ return false;
+ }
+
+ return $this->decode($cookie);
+ }
+
+ /**
+ * Remove the cookie
+ *
+ * @access public
+ * @return boolean
+ */
+ public function remove()
+ {
+ return setcookie(
+ self::COOKIE_NAME,
+ '',
+ time() - 3600,
+ $this->helper->url->dir(),
+ null,
+ $this->request->isHTTPS(),
+ true
+ );
+ }
+}
diff --git a/app/Core/Http/Request.php b/app/Core/Http/Request.php
new file mode 100644
index 00000000..1b3036d5
--- /dev/null
+++ b/app/Core/Http/Request.php
@@ -0,0 +1,337 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Pimple\Container;
+use Kanboard\Core\Base;
+
+/**
+ * Request class
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class Request extends Base
+{
+ /**
+ * Pointer to PHP environment variables
+ *
+ * @access private
+ * @var array
+ */
+ private $server;
+ private $get;
+ private $post;
+ private $files;
+ private $cookies;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ */
+ public function __construct(Container $container, array $server = array(), array $get = array(), array $post = array(), array $files = array(), array $cookies = array())
+ {
+ parent::__construct($container);
+ $this->server = empty($server) ? $_SERVER : $server;
+ $this->get = empty($get) ? $_GET : $get;
+ $this->post = empty($post) ? $_POST : $post;
+ $this->files = empty($files) ? $_FILES : $files;
+ $this->cookies = empty($cookies) ? $_COOKIE : $cookies;
+ }
+
+ /**
+ * Set GET parameters
+ *
+ * @param array $params
+ */
+ public function setParams(array $params)
+ {
+ $this->get = array_merge($this->get, $params);
+ }
+
+ /**
+ * Get query string string parameter
+ *
+ * @access public
+ * @param string $name Parameter name
+ * @param string $default_value Default value
+ * @return string
+ */
+ public function getStringParam($name, $default_value = '')
+ {
+ return isset($this->get[$name]) ? $this->get[$name] : $default_value;
+ }
+
+ /**
+ * Get query string integer parameter
+ *
+ * @access public
+ * @param string $name Parameter name
+ * @param integer $default_value Default value
+ * @return integer
+ */
+ public function getIntegerParam($name, $default_value = 0)
+ {
+ return isset($this->get[$name]) && ctype_digit($this->get[$name]) ? (int) $this->get[$name] : $default_value;
+ }
+
+ /**
+ * Get a form value
+ *
+ * @access public
+ * @param string $name Form field name
+ * @return string|null
+ */
+ public function getValue($name)
+ {
+ $values = $this->getValues();
+ return isset($values[$name]) ? $values[$name] : null;
+ }
+
+ /**
+ * Get form values and check for CSRF token
+ *
+ * @access public
+ * @return array
+ */
+ public function getValues()
+ {
+ if (! empty($this->post) && ! empty($this->post['csrf_token']) && $this->token->validateCSRFToken($this->post['csrf_token'])) {
+ unset($this->post['csrf_token']);
+ return $this->post;
+ }
+
+ return array();
+ }
+
+ /**
+ * Get the raw body of the HTTP request
+ *
+ * @access public
+ * @return string
+ */
+ public function getBody()
+ {
+ return file_get_contents('php://input');
+ }
+
+ /**
+ * Get the Json request body
+ *
+ * @access public
+ * @return array
+ */
+ public function getJson()
+ {
+ return json_decode($this->getBody(), true) ?: array();
+ }
+
+ /**
+ * Get the content of an uploaded file
+ *
+ * @access public
+ * @param string $name Form file name
+ * @return string
+ */
+ public function getFileContent($name)
+ {
+ if (isset($this->files[$name]['tmp_name'])) {
+ return file_get_contents($this->files[$name]['tmp_name']);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the path of an uploaded file
+ *
+ * @access public
+ * @param string $name Form file name
+ * @return string
+ */
+ public function getFilePath($name)
+ {
+ return isset($this->files[$name]['tmp_name']) ? $this->files[$name]['tmp_name'] : '';
+ }
+
+ /**
+ * Get info of an uploaded file
+ *
+ * @access public
+ * @param string $name Form file name
+ * @return array
+ */
+ public function getFileInfo($name)
+ {
+ return isset($this->files[$name]) ? $this->files[$name] : array();
+ }
+
+ /**
+ * Return HTTP method
+ *
+ * @access public
+ * @return bool
+ */
+ public function getMethod()
+ {
+ return $this->getServerVariable('REQUEST_METHOD');
+ }
+
+ /**
+ * Return true if the HTTP request is sent with the POST method
+ *
+ * @access public
+ * @return bool
+ */
+ public function isPost()
+ {
+ return $this->getServerVariable('REQUEST_METHOD') === 'POST';
+ }
+
+ /**
+ * Return true if the HTTP request is an Ajax request
+ *
+ * @access public
+ * @return bool
+ */
+ public function isAjax()
+ {
+ return $this->getHeader('X-Requested-With') === 'XMLHttpRequest';
+ }
+
+ /**
+ * Check if the page is requested through HTTPS
+ *
+ * Note: IIS return the value 'off' and other web servers an empty value when it's not HTTPS
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isHTTPS()
+ {
+ return isset($this->server['HTTPS']) && $this->server['HTTPS'] !== '' && $this->server['HTTPS'] !== 'off';
+ }
+
+ /**
+ * Get cookie value
+ *
+ * @access public
+ * @param string $name
+ * @return string
+ */
+ public function getCookie($name)
+ {
+ return isset($this->cookies[$name]) ? $this->cookies[$name] : '';
+ }
+
+ /**
+ * Return a HTTP header value
+ *
+ * @access public
+ * @param string $name Header name
+ * @return string
+ */
+ public function getHeader($name)
+ {
+ $name = 'HTTP_'.str_replace('-', '_', strtoupper($name));
+ return $this->getServerVariable($name);
+ }
+
+ /**
+ * Get remote user
+ *
+ * @access public
+ * @return string
+ */
+ public function getRemoteUser()
+ {
+ return $this->getServerVariable(REVERSE_PROXY_USER_HEADER);
+ }
+
+ /**
+ * Returns query string
+ *
+ * @access public
+ * @return string
+ */
+ public function getQueryString()
+ {
+ return $this->getServerVariable('QUERY_STRING');
+ }
+
+ /**
+ * Return URI
+ *
+ * @access public
+ * @return string
+ */
+ public function getUri()
+ {
+ return $this->getServerVariable('REQUEST_URI');
+ }
+
+ /**
+ * Get the user agent
+ *
+ * @access public
+ * @return string
+ */
+ public function getUserAgent()
+ {
+ return empty($this->server['HTTP_USER_AGENT']) ? t('Unknown') : $this->server['HTTP_USER_AGENT'];
+ }
+
+ /**
+ * Get the IP address of the user
+ *
+ * @access public
+ * @return string
+ */
+ public function getIpAddress()
+ {
+ $keys = array(
+ 'HTTP_CLIENT_IP',
+ 'HTTP_X_FORWARDED_FOR',
+ 'HTTP_X_FORWARDED',
+ 'HTTP_X_CLUSTER_CLIENT_IP',
+ 'HTTP_FORWARDED_FOR',
+ 'HTTP_FORWARDED',
+ 'REMOTE_ADDR'
+ );
+
+ foreach ($keys as $key) {
+ if ($this->getServerVariable($key) !== '') {
+ foreach (explode(',', $this->server[$key]) as $ipAddress) {
+ return trim($ipAddress);
+ }
+ }
+ }
+
+ return t('Unknown');
+ }
+
+ /**
+ * Get start time
+ *
+ * @access public
+ * @return float
+ */
+ public function getStartTime()
+ {
+ return $this->getServerVariable('REQUEST_TIME_FLOAT') ?: 0;
+ }
+
+ /**
+ * Get server variable
+ *
+ * @access public
+ * @param string $variable
+ * @return string
+ */
+ public function getServerVariable($variable)
+ {
+ return isset($this->server[$variable]) ? $this->server[$variable] : '';
+ }
+}
diff --git a/app/Core/Response.php b/app/Core/Http/Response.php
index 528a6302..d098f519 100644
--- a/app/Core/Response.php
+++ b/app/Core/Http/Response.php
@@ -1,14 +1,17 @@
<?php
-namespace Kanboard\Core;
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Csv;
/**
* Response class
*
- * @package core
+ * @package http
* @author Frederic Guillot
*/
-class Response
+class Response extends Base
{
/**
* Send no cache headers
@@ -44,6 +47,8 @@ class Response
public function forceDownload($filename)
{
header('Content-Disposition: attachment; filename="'.$filename.'"');
+ header('Content-Transfer-Encoding: binary');
+ header('Content-Type: application/octet-stream');
}
/**
@@ -55,7 +60,7 @@ class Response
public function status($status_code)
{
header('Status: '.$status_code);
- header($_SERVER['SERVER_PROTOCOL'].' '.$status_code);
+ header($this->request->getServerVariable('SERVER_PROTOCOL').' '.$status_code);
}
/**
@@ -63,11 +68,12 @@ class Response
*
* @access public
* @param string $url Redirection URL
+ * @param boolean $self If Ajax request and true: refresh the current page
*/
- public function redirect($url)
+ public function redirect($url, $self = false)
{
- if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
- header('X-Ajax-Redirect: '.$url);
+ if ($this->request->isAjax()) {
+ header('X-Ajax-Redirect: '.($self ? 'self' : $url));
} else {
header('Location: '.$url);
}
@@ -215,7 +221,6 @@ class Response
*/
public function csp(array $policies = array())
{
- $policies['default-src'] = "'self'";
$values = '';
foreach ($policies as $policy => $acl) {
@@ -252,7 +257,7 @@ class Response
*/
public function hsts()
{
- if (Request::isHTTPS()) {
+ if ($this->request->isHTTPS()) {
header('Strict-Transport-Security: max-age=31536000');
}
}
diff --git a/app/Core/Http/Route.php b/app/Core/Http/Route.php
new file mode 100644
index 00000000..7836146d
--- /dev/null
+++ b/app/Core/Http/Route.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+
+/**
+ * Route Handler
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class Route extends Base
+{
+ /**
+ * Flag that enable the routing table
+ *
+ * @access private
+ * @var boolean
+ */
+ private $activated = false;
+
+ /**
+ * Store routes for path lookup
+ *
+ * @access private
+ * @var array
+ */
+ private $paths = array();
+
+ /**
+ * Store routes for url lookup
+ *
+ * @access private
+ * @var array
+ */
+ private $urls = array();
+
+ /**
+ * Enable routing table
+ *
+ * @access public
+ * @return Route
+ */
+ public function enable()
+ {
+ $this->activated = true;
+ return $this;
+ }
+
+ /**
+ * Add route
+ *
+ * @access public
+ * @param string $path
+ * @param string $controller
+ * @param string $action
+ * @param string $plugin
+ * @return Route
+ */
+ public function addRoute($path, $controller, $action, $plugin = '')
+ {
+ if ($this->activated) {
+ $path = ltrim($path, '/');
+ $items = explode('/', $path);
+ $params = $this->findParams($items);
+
+ $this->paths[] = array(
+ 'items' => $items,
+ 'count' => count($items),
+ 'controller' => $controller,
+ 'action' => $action,
+ 'plugin' => $plugin,
+ );
+
+ $this->urls[$plugin][$controller][$action][] = array(
+ 'path' => $path,
+ 'params' => $params,
+ 'count' => count($params),
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Find a route according to the given path
+ *
+ * @access public
+ * @param string $path
+ * @return array
+ */
+ public function findRoute($path)
+ {
+ $items = explode('/', ltrim($path, '/'));
+ $count = count($items);
+
+ foreach ($this->paths as $route) {
+ if ($count === $route['count']) {
+ $params = array();
+
+ for ($i = 0; $i < $count; $i++) {
+ if ($route['items'][$i]{0} === ':') {
+ $params[substr($route['items'][$i], 1)] = $items[$i];
+ } elseif ($route['items'][$i] !== $items[$i]) {
+ break;
+ }
+ }
+
+ if ($i === $count) {
+ $this->request->setParams($params);
+ return array(
+ 'controller' => $route['controller'],
+ 'action' => $route['action'],
+ 'plugin' => $route['plugin'],
+ );
+ }
+ }
+ }
+
+ return array(
+ 'controller' => 'app',
+ 'action' => 'index',
+ 'plugin' => '',
+ );
+ }
+
+ /**
+ * Find route url
+ *
+ * @access public
+ * @param string $controller
+ * @param string $action
+ * @param array $params
+ * @param string $plugin
+ * @return string
+ */
+ public function findUrl($controller, $action, array $params = array(), $plugin = '')
+ {
+ if ($plugin === '' && isset($params['plugin'])) {
+ $plugin = $params['plugin'];
+ unset($params['plugin']);
+ }
+
+ if (! isset($this->urls[$plugin][$controller][$action])) {
+ return '';
+ }
+
+ foreach ($this->urls[$plugin][$controller][$action] as $route) {
+ if (array_diff_key($params, $route['params']) === array()) {
+ $url = $route['path'];
+ $i = 0;
+
+ foreach ($params as $variable => $value) {
+ $url = str_replace(':'.$variable, $value, $url);
+ $i++;
+ }
+
+ if ($i === $route['count']) {
+ return $url;
+ }
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Find url params
+ *
+ * @access public
+ * @param array $items
+ * @return array
+ */
+ public function findParams(array $items)
+ {
+ $params = array();
+
+ foreach ($items as $item) {
+ if ($item !== '' && $item{0} === ':') {
+ $params[substr($item, 1)] = true;
+ }
+ }
+
+ return $params;
+ }
+}
diff --git a/app/Core/Http/Router.php b/app/Core/Http/Router.php
new file mode 100644
index 00000000..0fe80ecc
--- /dev/null
+++ b/app/Core/Http/Router.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use RuntimeException;
+use Kanboard\Core\Base;
+
+/**
+ * Route Dispatcher
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class Router extends Base
+{
+ /**
+ * Plugin name
+ *
+ * @access private
+ * @var string
+ */
+ private $plugin = '';
+
+ /**
+ * Controller
+ *
+ * @access private
+ * @var string
+ */
+ private $controller = '';
+
+ /**
+ * Action
+ *
+ * @access private
+ * @var string
+ */
+ private $action = '';
+
+ /**
+ * Get plugin name
+ *
+ * @access public
+ * @return string
+ */
+ public function getPlugin()
+ {
+ return $this->plugin;
+ }
+
+ /**
+ * Get controller
+ *
+ * @access public
+ * @return string
+ */
+ public function getController()
+ {
+ return $this->controller;
+ }
+
+ /**
+ * Get action
+ *
+ * @access public
+ * @return string
+ */
+ public function getAction()
+ {
+ return $this->action;
+ }
+
+ /**
+ * Get the path to compare patterns
+ *
+ * @access public
+ * @return string
+ */
+ public function getPath()
+ {
+ $path = substr($this->request->getUri(), strlen($this->helper->url->dir()));
+
+ if ($this->request->getQueryString() !== '') {
+ $path = substr($path, 0, - strlen($this->request->getQueryString()) - 1);
+ }
+
+ if ($path !== '' && $path{0} === '/') {
+ $path = substr($path, 1);
+ }
+
+ return $path;
+ }
+
+ /**
+ * Find controller/action from the route table or from get arguments
+ *
+ * @access public
+ */
+ public function dispatch()
+ {
+ $controller = $this->request->getStringParam('controller');
+ $action = $this->request->getStringParam('action');
+ $plugin = $this->request->getStringParam('plugin');
+
+ if ($controller === '') {
+ $route = $this->route->findRoute($this->getPath());
+ $controller = $route['controller'];
+ $action = $route['action'];
+ $plugin = $route['plugin'];
+ }
+
+ $this->controller = ucfirst($this->sanitize($controller, 'app'));
+ $this->action = $this->sanitize($action, 'index');
+ $this->plugin = ucfirst($this->sanitize($plugin));
+
+ return $this->executeAction();
+ }
+
+ /**
+ * Check controller and action parameter
+ *
+ * @access public
+ * @param string $value
+ * @param string $default
+ * @return string
+ */
+ public function sanitize($value, $default = '')
+ {
+ return preg_match('/^[a-zA-Z_0-9]+$/', $value) ? $value : $default;
+ }
+
+ /**
+ * Execute controller action
+ *
+ * @access private
+ */
+ private function executeAction()
+ {
+ $class = $this->getControllerClassName();
+
+ if (! class_exists($class)) {
+ throw new RuntimeException('Controller not found');
+ }
+
+ if (! method_exists($class, $this->action)) {
+ throw new RuntimeException('Action not implemented');
+ }
+
+ $instance = new $class($this->container);
+ $instance->beforeAction();
+ $instance->{$this->action}();
+ return $instance;
+ }
+
+ /**
+ * Get controller class name
+ *
+ * @access private
+ * @return string
+ */
+ private function getControllerClassName()
+ {
+ if ($this->plugin !== '') {
+ return '\Kanboard\Plugin\\'.$this->plugin.'\Controller\\'.$this->controller;
+ }
+
+ return '\Kanboard\Controller\\'.$this->controller;
+ }
+}
diff --git a/app/Core/Ldap/Client.php b/app/Core/Ldap/Client.php
new file mode 100644
index 00000000..63149ae3
--- /dev/null
+++ b/app/Core/Ldap/Client.php
@@ -0,0 +1,165 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+use LogicException;
+
+/**
+ * LDAP Client
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class Client
+{
+ /**
+ * LDAP resource
+ *
+ * @access protected
+ * @var resource
+ */
+ protected $ldap;
+
+ /**
+ * Establish LDAP connection
+ *
+ * @static
+ * @access public
+ * @param string $username
+ * @param string $password
+ * @return Client
+ */
+ public static function connect($username = null, $password = null)
+ {
+ $client = new self;
+ $client->open($client->getLdapServer());
+ $username = $username ?: $client->getLdapUsername();
+ $password = $password ?: $client->getLdapPassword();
+
+ if (empty($username) && empty($password)) {
+ $client->useAnonymousAuthentication();
+ } else {
+ $client->authenticate($username, $password);
+ }
+
+ return $client;
+ }
+
+ /**
+ * Get server connection
+ *
+ * @access public
+ * @return resource
+ */
+ public function getConnection()
+ {
+ return $this->ldap;
+ }
+
+ /**
+ * Establish server connection
+ *
+ * @access public
+ * @param string $server LDAP server hostname or IP
+ * @param integer $port LDAP port
+ * @param boolean $tls Start TLS
+ * @param boolean $verify Skip SSL certificate verification
+ * @return Client
+ */
+ public function open($server, $port = LDAP_PORT, $tls = LDAP_START_TLS, $verify = LDAP_SSL_VERIFY)
+ {
+ if (! function_exists('ldap_connect')) {
+ throw new ClientException('LDAP: The PHP LDAP extension is required');
+ }
+
+ if (! $verify) {
+ putenv('LDAPTLS_REQCERT=never');
+ }
+
+ $this->ldap = ldap_connect($server, $port);
+
+ if ($this->ldap === false) {
+ throw new ClientException('LDAP: Unable to connect to the LDAP server');
+ }
+
+ ldap_set_option($this->ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
+ ldap_set_option($this->ldap, LDAP_OPT_REFERRALS, 0);
+ ldap_set_option($this->ldap, LDAP_OPT_NETWORK_TIMEOUT, 1);
+ ldap_set_option($this->ldap, LDAP_OPT_TIMELIMIT, 1);
+
+ if ($tls && ! @ldap_start_tls($this->ldap)) {
+ throw new ClientException('LDAP: Unable to start TLS');
+ }
+
+ return $this;
+ }
+
+ /**
+ * Anonymous authentication
+ *
+ * @access public
+ * @return boolean
+ */
+ public function useAnonymousAuthentication()
+ {
+ if (! @ldap_bind($this->ldap)) {
+ throw new ClientException('Unable to perform anonymous binding');
+ }
+
+ return true;
+ }
+
+ /**
+ * Authentication with username/password
+ *
+ * @access public
+ * @param string $bind_rdn
+ * @param string $bind_password
+ * @return boolean
+ */
+ public function authenticate($bind_rdn, $bind_password)
+ {
+ if (! @ldap_bind($this->ldap, $bind_rdn, $bind_password)) {
+ throw new ClientException('LDAP authentication failure for "'.$bind_rdn.'"');
+ }
+
+ return true;
+ }
+
+ /**
+ * Get LDAP server name
+ *
+ * @access public
+ * @return string
+ */
+ public function getLdapServer()
+ {
+ if (! LDAP_SERVER) {
+ throw new LogicException('LDAP server not configured, check the parameter LDAP_SERVER');
+ }
+
+ return LDAP_SERVER;
+ }
+
+ /**
+ * Get LDAP username (proxy auth)
+ *
+ * @access public
+ * @return string
+ */
+ public function getLdapUsername()
+ {
+ return LDAP_USERNAME;
+ }
+
+ /**
+ * Get LDAP password (proxy auth)
+ *
+ * @access public
+ * @return string
+ */
+ public function getLdapPassword()
+ {
+ return LDAP_PASSWORD;
+ }
+}
diff --git a/app/Core/Ldap/ClientException.php b/app/Core/Ldap/ClientException.php
new file mode 100644
index 00000000..a0f9f842
--- /dev/null
+++ b/app/Core/Ldap/ClientException.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+use Exception;
+
+/**
+ * LDAP Client Exception
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class ClientException extends Exception
+{
+}
diff --git a/app/Core/Ldap/Entries.php b/app/Core/Ldap/Entries.php
new file mode 100644
index 00000000..0e779342
--- /dev/null
+++ b/app/Core/Ldap/Entries.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+/**
+ * LDAP Entries
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class Entries
+{
+ /**
+ * LDAP entries
+ *
+ * @access protected
+ * @var array
+ */
+ protected $entries = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param array $entries
+ */
+ public function __construct(array $entries)
+ {
+ $this->entries = $entries;
+ }
+
+ /**
+ * Get all entries
+ *
+ * @access public
+ * @return Entry[]
+ */
+ public function getAll()
+ {
+ $entities = array();
+
+ if (! isset($this->entries['count'])) {
+ return $entities;
+ }
+
+ for ($i = 0; $i < $this->entries['count']; $i++) {
+ $entities[] = new Entry($this->entries[$i]);
+ }
+
+ return $entities;
+ }
+
+ /**
+ * Get first entry
+ *
+ * @access public
+ * @return Entry
+ */
+ public function getFirstEntry()
+ {
+ return new Entry(isset($this->entries[0]) ? $this->entries[0] : array());
+ }
+}
diff --git a/app/Core/Ldap/Entry.php b/app/Core/Ldap/Entry.php
new file mode 100644
index 00000000..0b99a58b
--- /dev/null
+++ b/app/Core/Ldap/Entry.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+/**
+ * LDAP Entry
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class Entry
+{
+ /**
+ * LDAP entry
+ *
+ * @access protected
+ * @var array
+ */
+ protected $entry = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param array $entry
+ */
+ public function __construct(array $entry)
+ {
+ $this->entry = $entry;
+ }
+
+ /**
+ * Get all attribute values
+ *
+ * @access public
+ * @param string $attribute
+ * @return string[]
+ */
+ public function getAll($attribute)
+ {
+ $attributes = array();
+
+ if (! isset($this->entry[$attribute]['count'])) {
+ return $attributes;
+ }
+
+ for ($i = 0; $i < $this->entry[$attribute]['count']; $i++) {
+ $attributes[] = $this->entry[$attribute][$i];
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Get first attribute value
+ *
+ * @access public
+ * @param string $attribute
+ * @param string $default
+ * @return string
+ */
+ public function getFirstValue($attribute, $default = '')
+ {
+ return isset($this->entry[$attribute][0]) ? $this->entry[$attribute][0] : $default;
+ }
+
+ /**
+ * Get entry distinguished name
+ *
+ * @access public
+ * @return string
+ */
+ public function getDn()
+ {
+ return isset($this->entry['dn']) ? $this->entry['dn'] : '';
+ }
+
+ /**
+ * Return true if the given value exists in attribute list
+ *
+ * @access public
+ * @param string $attribute
+ * @param string $value
+ * @return boolean
+ */
+ public function hasValue($attribute, $value)
+ {
+ $attributes = $this->getAll($attribute);
+ return in_array($value, $attributes);
+ }
+}
diff --git a/app/Core/Ldap/Group.php b/app/Core/Ldap/Group.php
new file mode 100644
index 00000000..634d47ee
--- /dev/null
+++ b/app/Core/Ldap/Group.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+use LogicException;
+use Kanboard\Group\LdapGroupProvider;
+
+/**
+ * LDAP Group Finder
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class Group
+{
+ /**
+ * Query
+ *
+ * @access protected
+ * @var Query
+ */
+ protected $query;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param Query $query
+ */
+ public function __construct(Query $query)
+ {
+ $this->query = $query;
+ }
+
+ /**
+ * Get groups
+ *
+ * @static
+ * @access public
+ * @param Client $client
+ * @param string $query
+ * @return array
+ */
+ public static function getGroups(Client $client, $query)
+ {
+ $className = get_called_class();
+ $self = new $className(new Query($client));
+ return $self->find($query);
+ }
+
+ /**
+ * Find groups
+ *
+ * @access public
+ * @param string $query
+ * @return array
+ */
+ public function find($query)
+ {
+ $this->query->execute($this->getBasDn(), $query, $this->getAttributes());
+ $groups = array();
+
+ if ($this->query->hasResult()) {
+ $groups = $this->build();
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Build groups list
+ *
+ * @access protected
+ * @return array
+ */
+ protected function build()
+ {
+ $groups = array();
+
+ foreach ($this->query->getEntries()->getAll() as $entry) {
+ $groups[] = new LdapGroupProvider($entry->getDn(), $entry->getFirstValue($this->getAttributeName()));
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Ge the list of attributes to fetch when reading the LDAP group entry
+ *
+ * Must returns array with index that start at 0 otherwise ldap_search returns a warning "Array initialization wrong"
+ *
+ * @access public
+ * @return array
+ */
+ public function getAttributes()
+ {
+ return array_values(array_filter(array(
+ $this->getAttributeName(),
+ )));
+ }
+
+ /**
+ * Get LDAP group name attribute
+ *
+ * @access public
+ * @return string
+ */
+ public function getAttributeName()
+ {
+ if (! LDAP_GROUP_ATTRIBUTE_NAME) {
+ throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_GROUP_ATTRIBUTE_NAME');
+ }
+
+ return LDAP_GROUP_ATTRIBUTE_NAME;
+ }
+
+ /**
+ * Get LDAP group base DN
+ *
+ * @access public
+ * @return string
+ */
+ public function getBasDn()
+ {
+ if (! LDAP_GROUP_BASE_DN) {
+ throw new LogicException('LDAP group base DN empty, check the parameter LDAP_GROUP_BASE_DN');
+ }
+
+ return LDAP_GROUP_BASE_DN;
+ }
+}
diff --git a/app/Core/Ldap/Query.php b/app/Core/Ldap/Query.php
new file mode 100644
index 00000000..e03495ec
--- /dev/null
+++ b/app/Core/Ldap/Query.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+/**
+ * LDAP Query
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class Query
+{
+ /**
+ * LDAP client
+ *
+ * @access protected
+ * @var Client
+ */
+ protected $client = null;
+
+ /**
+ * Query result
+ *
+ * @access protected
+ * @var array
+ */
+ protected $entries = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param Client $client
+ */
+ public function __construct(Client $client)
+ {
+ $this->client = $client;
+ }
+
+ /**
+ * Execute query
+ *
+ * @access public
+ * @param string $baseDn
+ * @param string $filter
+ * @param array $attributes
+ * @return Query
+ */
+ public function execute($baseDn, $filter, array $attributes)
+ {
+ $sr = ldap_search($this->client->getConnection(), $baseDn, $filter, $attributes);
+ if ($sr === false) {
+ return $this;
+ }
+
+ $entries = ldap_get_entries($this->client->getConnection(), $sr);
+ if ($entries === false || count($entries) === 0 || $entries['count'] == 0) {
+ return $this;
+ }
+
+ $this->entries = $entries;
+
+ return $this;
+ }
+
+ /**
+ * Return true if the query returned a result
+ *
+ * @access public
+ * @return boolean
+ */
+ public function hasResult()
+ {
+ return ! empty($this->entries);
+ }
+
+ /**
+ * Get LDAP Entries
+ *
+ * @access public
+ * @return Entities
+ */
+ public function getEntries()
+ {
+ return new Entries($this->entries);
+ }
+}
diff --git a/app/Core/Ldap/User.php b/app/Core/Ldap/User.php
new file mode 100644
index 00000000..d36d6f34
--- /dev/null
+++ b/app/Core/Ldap/User.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+use LogicException;
+use Kanboard\Core\Security\Role;
+use Kanboard\User\LdapUserProvider;
+
+/**
+ * LDAP User Finder
+ *
+ * @package ldap
+ * @author Frederic Guillot
+ */
+class User
+{
+ /**
+ * Query
+ *
+ * @access protected
+ * @var Query
+ */
+ protected $query;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param Query $query
+ */
+ public function __construct(Query $query)
+ {
+ $this->query = $query;
+ }
+
+ /**
+ * Get user profile
+ *
+ * @static
+ * @access public
+ * @param Client $client
+ * @param string $username
+ * @return LdapUserProvider
+ */
+ public static function getUser(Client $client, $username)
+ {
+ $className = get_called_class();
+ $self = new $className(new Query($client));
+ return $self->find($self->getLdapUserPattern($username));
+ }
+
+ /**
+ * Find user
+ *
+ * @access public
+ * @param string $query
+ * @return null|LdapUserProvider
+ */
+ public function find($query)
+ {
+ $this->query->execute($this->getBasDn(), $query, $this->getAttributes());
+ $user = null;
+
+ if ($this->query->hasResult()) {
+ $user = $this->build();
+ }
+
+ return $user;
+ }
+
+ /**
+ * Build user profile
+ *
+ * @access protected
+ * @return LdapUserProvider
+ */
+ protected function build()
+ {
+ $entry = $this->query->getEntries()->getFirstEntry();
+ $role = Role::APP_USER;
+
+ if ($entry->hasValue($this->getAttributeGroup(), $this->getGroupAdminDn())) {
+ $role = Role::APP_ADMIN;
+ } elseif ($entry->hasValue($this->getAttributeGroup(), $this->getGroupManagerDn())) {
+ $role = Role::APP_MANAGER;
+ }
+
+ return new LdapUserProvider(
+ $entry->getDn(),
+ $entry->getFirstValue($this->getAttributeUsername()),
+ $entry->getFirstValue($this->getAttributeName()),
+ $entry->getFirstValue($this->getAttributeEmail()),
+ $role,
+ $entry->getAll($this->getAttributeGroup())
+ );
+ }
+
+ /**
+ * Ge the list of attributes to fetch when reading the LDAP user entry
+ *
+ * Must returns array with index that start at 0 otherwise ldap_search returns a warning "Array initialization wrong"
+ *
+ * @access public
+ * @return array
+ */
+ public function getAttributes()
+ {
+ return array_values(array_filter(array(
+ $this->getAttributeUsername(),
+ $this->getAttributeName(),
+ $this->getAttributeEmail(),
+ $this->getAttributeGroup(),
+ )));
+ }
+
+ /**
+ * Get LDAP account id attribute
+ *
+ * @access public
+ * @return string
+ */
+ public function getAttributeUsername()
+ {
+ if (! LDAP_USER_ATTRIBUTE_USERNAME) {
+ throw new LogicException('LDAP username attribute empty, check the parameter LDAP_USER_ATTRIBUTE_USERNAME');
+ }
+
+ return LDAP_USER_ATTRIBUTE_USERNAME;
+ }
+
+ /**
+ * Get LDAP user name attribute
+ *
+ * @access public
+ * @return string
+ */
+ public function getAttributeName()
+ {
+ if (! LDAP_USER_ATTRIBUTE_FULLNAME) {
+ throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_USER_ATTRIBUTE_FULLNAME');
+ }
+
+ return LDAP_USER_ATTRIBUTE_FULLNAME;
+ }
+
+ /**
+ * Get LDAP account email attribute
+ *
+ * @access public
+ * @return string
+ */
+ public function getAttributeEmail()
+ {
+ if (! LDAP_USER_ATTRIBUTE_EMAIL) {
+ throw new LogicException('LDAP email attribute empty, check the parameter LDAP_USER_ATTRIBUTE_EMAIL');
+ }
+
+ return LDAP_USER_ATTRIBUTE_EMAIL;
+ }
+
+ /**
+ * Get LDAP account memberof attribute
+ *
+ * @access public
+ * @return string
+ */
+ public function getAttributeGroup()
+ {
+ return LDAP_USER_ATTRIBUTE_GROUPS;
+ }
+
+ /**
+ * Get LDAP admin group DN
+ *
+ * @access public
+ * @return string
+ */
+ public function getGroupAdminDn()
+ {
+ return LDAP_GROUP_ADMIN_DN;
+ }
+
+ /**
+ * Get LDAP application manager group DN
+ *
+ * @access public
+ * @return string
+ */
+ public function getGroupManagerDn()
+ {
+ return LDAP_GROUP_MANAGER_DN;
+ }
+
+ /**
+ * Get LDAP user base DN
+ *
+ * @access public
+ * @return string
+ */
+ public function getBasDn()
+ {
+ if (! LDAP_USER_BASE_DN) {
+ throw new LogicException('LDAP user base DN empty, check the parameter LDAP_USER_BASE_DN');
+ }
+
+ return LDAP_USER_BASE_DN;
+ }
+
+ /**
+ * Get LDAP user pattern
+ *
+ * @access public
+ * @param string $username
+ * @return string
+ */
+ public function getLdapUserPattern($username)
+ {
+ if (! LDAP_USER_FILTER) {
+ throw new LogicException('LDAP user filter empty, check the parameter LDAP_USER_FILTER');
+ }
+
+ return sprintf(LDAP_USER_FILTER, $username);
+ }
+}
diff --git a/app/Core/Lexer.php b/app/Core/Lexer.php
index ca2ef895..df2d90ae 100644
--- a/app/Core/Lexer.php
+++ b/app/Core/Lexer.php
@@ -39,6 +39,7 @@ class Lexer
"/^(swimlane:)/" => 'T_SWIMLANE',
"/^(ref:)/" => 'T_REFERENCE',
"/^(reference:)/" => 'T_REFERENCE',
+ "/^(link:)/" => 'T_LINK',
"/^(\s+)/" => 'T_WHITESPACE',
'/^([<=>]{0,2}[0-9]{4}-[0-9]{2}-[0-9]{2})/' => 'T_DATE',
'/^(yesterday|tomorrow|today)/' => 'T_DATE',
@@ -118,6 +119,7 @@ class Lexer
case 'T_COLUMN':
case 'T_PROJECT':
case 'T_SWIMLANE':
+ case 'T_LINK':
$next = next($tokens);
if ($next !== false && $next['token'] === 'T_STRING') {
diff --git a/app/Core/Mail/Client.php b/app/Core/Mail/Client.php
index 52caef73..e1f31696 100644
--- a/app/Core/Mail/Client.php
+++ b/app/Core/Mail/Client.php
@@ -45,19 +45,21 @@ class Client extends Base
*/
public function send($email, $name, $subject, $html)
{
- $this->container['logger']->debug('Sending email to '.$email.' ('.MAIL_TRANSPORT.')');
+ if (! empty($email)) {
+ $this->logger->debug('Sending email to '.$email.' ('.MAIL_TRANSPORT.')');
- $start_time = microtime(true);
- $author = 'Kanboard';
+ $start_time = microtime(true);
+ $author = 'Kanboard';
- if ($this->userSession->isLogged()) {
- $author = e('%s via Kanboard', $this->user->getFullname($this->session['user']));
- }
+ if ($this->userSession->isLogged()) {
+ $author = e('%s via Kanboard', $this->helper->user->getFullname());
+ }
- $this->getTransport(MAIL_TRANSPORT)->sendEmail($email, $name, $subject, $html, $author);
+ $this->getTransport(MAIL_TRANSPORT)->sendEmail($email, $name, $subject, $html, $author);
- if (DEBUG) {
- $this->logger->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds');
+ if (DEBUG) {
+ $this->logger->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds');
+ }
}
return $this;
diff --git a/app/Core/Mail/Transport/Mail.php b/app/Core/Mail/Transport/Mail.php
index 4d833f8f..aff3ee20 100644
--- a/app/Core/Mail/Transport/Mail.php
+++ b/app/Core/Mail/Transport/Mail.php
@@ -46,7 +46,7 @@ class Mail extends Base implements ClientInterface
* Get SwiftMailer transport
*
* @access protected
- * @return \Swift_Transport
+ * @return \Swift_Transport|\Swift_MailTransport|\Swift_SmtpTransport|\Swift_SendmailTransport
*/
protected function getTransport()
{
diff --git a/app/Core/Mail/Transport/Sendmail.php b/app/Core/Mail/Transport/Sendmail.php
index 849e3385..039be705 100644
--- a/app/Core/Mail/Transport/Sendmail.php
+++ b/app/Core/Mail/Transport/Sendmail.php
@@ -16,7 +16,7 @@ class Sendmail extends Mail
* Get SwiftMailer transport
*
* @access protected
- * @return \Swift_Transport
+ * @return \Swift_Transport|\Swift_MailTransport|\Swift_SmtpTransport|\Swift_SendmailTransport
*/
protected function getTransport()
{
diff --git a/app/Core/Mail/Transport/Smtp.php b/app/Core/Mail/Transport/Smtp.php
index 757408ea..66f0a3aa 100644
--- a/app/Core/Mail/Transport/Smtp.php
+++ b/app/Core/Mail/Transport/Smtp.php
@@ -16,7 +16,7 @@ class Smtp extends Mail
* Get SwiftMailer transport
*
* @access protected
- * @return \Swift_Transport
+ * @return \Swift_Transport|\Swift_MailTransport|\Swift_SmtpTransport|\Swift_SendmailTransport
*/
protected function getTransport()
{
diff --git a/app/Core/Markdown.php b/app/Core/Markdown.php
index f08c486a..827fd0df 100644
--- a/app/Core/Markdown.php
+++ b/app/Core/Markdown.php
@@ -3,7 +3,7 @@
namespace Kanboard\Core;
use Parsedown;
-use Kanboard\Helper\Url;
+use Pimple\Container;
/**
* Specific Markdown rules for Kanboard
@@ -14,22 +14,51 @@ use Kanboard\Helper\Url;
*/
class Markdown extends Parsedown
{
- private $link;
- private $helper;
+ /**
+ * Link params for tasks
+ *
+ * @access private
+ * @var array
+ */
+ private $link = array();
- public function __construct($link, Url $helper)
+ /**
+ * Container
+ *
+ * @access private
+ * @var Container
+ */
+ private $container;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param Container $container
+ * @param array $link
+ */
+ public function __construct(Container $container, array $link)
{
$this->link = $link;
- $this->helper = $helper;
+ $this->container = $container;
$this->InlineTypes['#'][] = 'TaskLink';
- $this->inlineMarkerList .= '#';
+ $this->InlineTypes['@'][] = 'UserLink';
+ $this->inlineMarkerList .= '#@';
}
- protected function inlineTaskLink($Excerpt)
+ /**
+ * Handle Task Links
+ *
+ * Replace "#123" by a link to the task
+ *
+ * @access public
+ * @param array $Excerpt
+ * @return array
+ */
+ protected function inlineTaskLink(array $Excerpt)
{
- // Replace task #123 by a link to the task
if (! empty($this->link) && preg_match('!#(\d+)!i', $Excerpt['text'], $matches)) {
- $url = $this->helper->href(
+ $url = $this->container['helper']->url->href(
$this->link['controller'],
$this->link['action'],
$this->link['params'] + array('task_id' => $matches[1])
@@ -40,7 +69,38 @@ class Markdown extends Parsedown
'element' => array(
'name' => 'a',
'text' => $matches[0],
- 'attributes' => array('href' => $url)));
+ 'attributes' => array('href' => $url)
+ ),
+ );
+ }
+ }
+
+ /**
+ * Handle User Mentions
+ *
+ * Replace "@username" by a link to the user
+ *
+ * @access public
+ * @param array $Excerpt
+ * @return array
+ */
+ protected function inlineUserLink(array $Excerpt)
+ {
+ if (preg_match('/^@([^\s]+)/', $Excerpt['text'], $matches)) {
+ $user_id = $this->container['user']->getIdByUsername($matches[1]);
+
+ if (! empty($user_id)) {
+ $url = $this->container['helper']->url->href('user', 'profile', array('user_id' => $user_id));
+
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => array(
+ 'name' => 'a',
+ 'text' => $matches[0],
+ 'attributes' => array('href' => $url, 'class' => 'user-mention-link'),
+ ),
+ );
+ }
}
}
}
diff --git a/app/Core/Plugin/Loader.php b/app/Core/Plugin/Loader.php
index 4769e5cb..530d9b40 100644
--- a/app/Core/Plugin/Loader.php
+++ b/app/Core/Plugin/Loader.php
@@ -4,6 +4,7 @@ namespace Kanboard\Core\Plugin;
use DirectoryIterator;
use PDOException;
+use LogicException;
use RuntimeException;
use Kanboard\Core\Tool;
@@ -59,6 +60,11 @@ class Loader extends \Kanboard\Core\Base
public function load($plugin)
{
$class = '\Kanboard\Plugin\\'.$plugin.'\\Plugin';
+
+ if (! class_exists($class)) {
+ throw new LogicException('Unable to load this plugin class '.$class);
+ }
+
$instance = new $class($this->container);
Tool::buildDic($this->container, $instance->getClasses());
diff --git a/app/Core/Request.php b/app/Core/Request.php
deleted file mode 100644
index 5eda2d02..00000000
--- a/app/Core/Request.php
+++ /dev/null
@@ -1,240 +0,0 @@
-<?php
-
-namespace Kanboard\Core;
-
-/**
- * Request class
- *
- * @package core
- * @author Frederic Guillot
- */
-class Request
-{
- /**
- * Get URL string parameter
- *
- * @access public
- * @param string $name Parameter name
- * @param string $default_value Default value
- * @return string
- */
- public function getStringParam($name, $default_value = '')
- {
- return isset($_GET[$name]) ? $_GET[$name] : $default_value;
- }
-
- /**
- * Get URL integer parameter
- *
- * @access public
- * @param string $name Parameter name
- * @param integer $default_value Default value
- * @return integer
- */
- public function getIntegerParam($name, $default_value = 0)
- {
- return isset($_GET[$name]) && ctype_digit($_GET[$name]) ? (int) $_GET[$name] : $default_value;
- }
-
- /**
- * Get a form value
- *
- * @access public
- * @param string $name Form field name
- * @return string|null
- */
- public function getValue($name)
- {
- $values = $this->getValues();
- return isset($values[$name]) ? $values[$name] : null;
- }
-
- /**
- * Get form values and check for CSRF token
- *
- * @access public
- * @return array
- */
- public function getValues()
- {
- if (! empty($_POST) && Security::validateCSRFFormToken($_POST)) {
- return $_POST;
- }
-
- return array();
- }
-
- /**
- * Get the raw body of the HTTP request
- *
- * @access public
- * @return string
- */
- public function getBody()
- {
- return file_get_contents('php://input');
- }
-
- /**
- * Get the Json request body
- *
- * @access public
- * @return array
- */
- public function getJson()
- {
- return json_decode($this->getBody(), true) ?: array();
- }
-
- /**
- * Get the content of an uploaded file
- *
- * @access public
- * @param string $name Form file name
- * @return string
- */
- public function getFileContent($name)
- {
- if (isset($_FILES[$name])) {
- return file_get_contents($_FILES[$name]['tmp_name']);
- }
-
- return '';
- }
-
- /**
- * Get the path of an uploaded file
- *
- * @access public
- * @param string $name Form file name
- * @return string
- */
- public function getFilePath($name)
- {
- return isset($_FILES[$name]['tmp_name']) ? $_FILES[$name]['tmp_name'] : '';
- }
-
- /**
- * Return true if the HTTP request is sent with the POST method
- *
- * @access public
- * @return bool
- */
- public function isPost()
- {
- return isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST';
- }
-
- /**
- * Return true if the HTTP request is an Ajax request
- *
- * @access public
- * @return bool
- */
- public function isAjax()
- {
- return $this->getHeader('X-Requested-With') === 'XMLHttpRequest';
- }
-
- /**
- * Check if the page is requested through HTTPS
- *
- * Note: IIS return the value 'off' and other web servers an empty value when it's not HTTPS
- *
- * @static
- * @access public
- * @return boolean
- */
- public static function isHTTPS()
- {
- return isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && $_SERVER['HTTPS'] !== 'off';
- }
-
- /**
- * Return a HTTP header value
- *
- * @access public
- * @param string $name Header name
- * @return string
- */
- public function getHeader($name)
- {
- $name = 'HTTP_'.str_replace('-', '_', strtoupper($name));
- return isset($_SERVER[$name]) ? $_SERVER[$name] : '';
- }
-
- /**
- * Returns current request's query string, useful for redirecting
- *
- * @access public
- * @return string
- */
- public function getQueryString()
- {
- return isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
- }
-
- /**
- * Returns uri
- *
- * @access public
- * @return string
- */
- public function getUri()
- {
- return isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
- }
-
- /**
- * Get the user agent
- *
- * @static
- * @access public
- * @return string
- */
- public static function getUserAgent()
- {
- return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT'];
- }
-
- /**
- * Get the real IP address of the user
- *
- * @static
- * @access public
- * @param bool $only_public Return only public IP address
- * @return string
- */
- public static function getIpAddress($only_public = false)
- {
- $keys = array(
- 'HTTP_CLIENT_IP',
- 'HTTP_X_FORWARDED_FOR',
- 'HTTP_X_FORWARDED',
- 'HTTP_X_CLUSTER_CLIENT_IP',
- 'HTTP_FORWARDED_FOR',
- 'HTTP_FORWARDED',
- 'REMOTE_ADDR'
- );
-
- foreach ($keys as $key) {
- if (isset($_SERVER[$key])) {
- foreach (explode(',', $_SERVER[$key]) as $ip_address) {
- $ip_address = trim($ip_address);
-
- if ($only_public) {
-
- // Return only public IP address
- if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {
- return $ip_address;
- }
- } else {
- return $ip_address;
- }
- }
- }
- }
-
- return t('Unknown');
- }
-}
diff --git a/app/Core/Router.php b/app/Core/Router.php
deleted file mode 100644
index 843f5139..00000000
--- a/app/Core/Router.php
+++ /dev/null
@@ -1,229 +0,0 @@
-<?php
-
-namespace Kanboard\Core;
-
-use RuntimeException;
-
-/**
- * Router class
- *
- * @package core
- * @author Frederic Guillot
- */
-class Router extends Base
-{
- /**
- * Controller
- *
- * @access private
- * @var string
- */
- private $controller = '';
-
- /**
- * Action
- *
- * @access private
- * @var string
- */
- private $action = '';
-
- /**
- * Store routes for path lookup
- *
- * @access private
- * @var array
- */
- private $paths = array();
-
- /**
- * Store routes for url lookup
- *
- * @access private
- * @var array
- */
- private $urls = array();
-
- /**
- * Get action
- *
- * @access public
- * @return string
- */
- public function getAction()
- {
- return $this->action;
- }
-
- /**
- * Get controller
- *
- * @access public
- * @return string
- */
- public function getController()
- {
- return $this->controller;
- }
-
- /**
- * Get the path to compare patterns
- *
- * @access public
- * @param string $uri
- * @param string $query_string
- * @return string
- */
- public function getPath($uri, $query_string = '')
- {
- $path = substr($uri, strlen($this->helper->url->dir()));
-
- if (! empty($query_string)) {
- $path = substr($path, 0, - strlen($query_string) - 1);
- }
-
- if (! empty($path) && $path{0} === '/') {
- $path = substr($path, 1);
- }
-
- return $path;
- }
-
- /**
- * Add route
- *
- * @access public
- * @param string $path
- * @param string $controller
- * @param string $action
- * @param array $params
- */
- public function addRoute($path, $controller, $action, array $params = array())
- {
- $pattern = explode('/', $path);
-
- $this->paths[] = array(
- 'pattern' => $pattern,
- 'count' => count($pattern),
- 'controller' => $controller,
- 'action' => $action,
- );
-
- $this->urls[$controller][$action][] = array(
- 'path' => $path,
- 'params' => array_flip($params),
- 'count' => count($params),
- );
- }
-
- /**
- * Find a route according to the given path
- *
- * @access public
- * @param string $path
- * @return array
- */
- public function findRoute($path)
- {
- $parts = explode('/', $path);
- $count = count($parts);
-
- foreach ($this->paths as $route) {
- if ($count === $route['count']) {
- $params = array();
-
- for ($i = 0; $i < $count; $i++) {
- if ($route['pattern'][$i]{0} === ':') {
- $params[substr($route['pattern'][$i], 1)] = $parts[$i];
- } elseif ($route['pattern'][$i] !== $parts[$i]) {
- break;
- }
- }
-
- if ($i === $count) {
- $_GET = array_merge($_GET, $params);
- return array($route['controller'], $route['action']);
- }
- }
- }
-
- return array('app', 'index');
- }
-
- /**
- * Find route url
- *
- * @access public
- * @param string $controller
- * @param string $action
- * @param array $params
- * @return string
- */
- public function findUrl($controller, $action, array $params = array())
- {
- if (! isset($this->urls[$controller][$action])) {
- return '';
- }
-
- foreach ($this->urls[$controller][$action] as $pattern) {
- if (array_diff_key($params, $pattern['params']) === array()) {
- $url = $pattern['path'];
- $i = 0;
-
- foreach ($params as $variable => $value) {
- $url = str_replace(':'.$variable, $value, $url);
- $i++;
- }
-
- if ($i === $pattern['count']) {
- return $url;
- }
- }
- }
-
- return '';
- }
-
- /**
- * Check controller and action parameter
- *
- * @access public
- * @param string $value Controller or action name
- * @param string $default_value Default value if validation fail
- * @return string
- */
- public function sanitize($value, $default_value)
- {
- return ! preg_match('/^[a-zA-Z_0-9]+$/', $value) ? $default_value : $value;
- }
-
- /**
- * Find controller/action from the route table or from get arguments
- *
- * @access public
- * @param string $uri
- * @param string $query_string
- */
- public function dispatch($uri, $query_string = '')
- {
- if (! empty($_GET['controller']) && ! empty($_GET['action'])) {
- $this->controller = $this->sanitize($_GET['controller'], 'app');
- $this->action = $this->sanitize($_GET['action'], 'index');
- $plugin = ! empty($_GET['plugin']) ? $this->sanitize($_GET['plugin'], '') : '';
- } else {
- list($this->controller, $this->action) = $this->findRoute($this->getPath($uri, $query_string)); // TODO: add plugin for routes
- $plugin = '';
- }
-
- $class = '\Kanboard\\';
- $class .= empty($plugin) ? 'Controller\\'.ucfirst($this->controller) : 'Plugin\\'.ucfirst($plugin).'\Controller\\'.ucfirst($this->controller);
-
- if (! class_exists($class) || ! method_exists($class, $this->action)) {
- throw new RuntimeException('Controller or method not found for the given url!');
- }
-
- $instance = new $class($this->container);
- $instance->beforeAction($this->controller, $this->action);
- $instance->{$this->action}();
- }
-}
diff --git a/app/Core/Security.php b/app/Core/Security.php
deleted file mode 100644
index 54207ee1..00000000
--- a/app/Core/Security.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php
-
-namespace Kanboard\Core;
-
-/**
- * Security class
- *
- * @package core
- * @author Frederic Guillot
- */
-class Security
-{
- /**
- * Generate a random token with different methods: openssl or /dev/urandom or fallback to uniqid()
- *
- * @static
- * @access public
- * @return string Random token
- */
- public static function generateToken()
- {
- if (function_exists('openssl_random_pseudo_bytes')) {
- return bin2hex(\openssl_random_pseudo_bytes(30));
- } elseif (ini_get('open_basedir') === '' && strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
- return hash('sha256', file_get_contents('/dev/urandom', false, null, 0, 30));
- }
-
- return hash('sha256', uniqid(mt_rand(), true));
- }
-
- /**
- * Generate and store a CSRF token in the current session
- *
- * @static
- * @access public
- * @return string Random token
- */
- public static function getCSRFToken()
- {
- $nonce = self::generateToken();
-
- if (empty($_SESSION['csrf_tokens'])) {
- $_SESSION['csrf_tokens'] = array();
- }
-
- $_SESSION['csrf_tokens'][$nonce] = true;
-
- return $nonce;
- }
-
- /**
- * Check if the token exists for the current session (a token can be used only one time)
- *
- * @static
- * @access public
- * @param string $token CSRF token
- * @return bool
- */
- public static function validateCSRFToken($token)
- {
- if (isset($_SESSION['csrf_tokens'][$token])) {
- unset($_SESSION['csrf_tokens'][$token]);
- return true;
- }
-
- return false;
- }
-
- /**
- * Check if the token used in a form is correct and then remove the value
- *
- * @static
- * @access public
- * @param array $values Form values
- * @return bool
- */
- public static function validateCSRFFormToken(array &$values)
- {
- if (! empty($values['csrf_token']) && self::validateCSRFToken($values['csrf_token'])) {
- unset($values['csrf_token']);
- return true;
- }
-
- return false;
- }
-}
diff --git a/app/Core/Security/AccessMap.php b/app/Core/Security/AccessMap.php
new file mode 100644
index 00000000..f34c4b00
--- /dev/null
+++ b/app/Core/Security/AccessMap.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Access Map Definition
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+class AccessMap
+{
+ /**
+ * Default role
+ *
+ * @access private
+ * @var string
+ */
+ private $defaultRole = '';
+
+ /**
+ * Role hierarchy
+ *
+ * @access private
+ * @var array
+ */
+ private $hierarchy = array();
+
+ /**
+ * Access map
+ *
+ * @access private
+ * @var array
+ */
+ private $map = array();
+
+ /**
+ * Define the default role when nothing match
+ *
+ * @access public
+ * @param string $role
+ * @return Acl
+ */
+ public function setDefaultRole($role)
+ {
+ $this->defaultRole = $role;
+ return $this;
+ }
+
+ /**
+ * Define role hierarchy
+ *
+ * @access public
+ * @param string $role
+ * @param array $subroles
+ * @return Acl
+ */
+ public function setRoleHierarchy($role, array $subroles)
+ {
+ foreach ($subroles as $subrole) {
+ if (isset($this->hierarchy[$subrole])) {
+ $this->hierarchy[$subrole][] = $role;
+ } else {
+ $this->hierarchy[$subrole] = array($role);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get computed role hierarchy
+ *
+ * @access public
+ * @param string $role
+ * @return array
+ */
+ public function getRoleHierarchy($role)
+ {
+ $roles = array($role);
+
+ if (isset($this->hierarchy[$role])) {
+ $roles = array_merge($roles, $this->hierarchy[$role]);
+ }
+
+ return $roles;
+ }
+
+ /**
+ * Get the highest role from a list
+ *
+ * @access public
+ * @param array $roles
+ * @return string
+ */
+ public function getHighestRole(array $roles)
+ {
+ $rank = array();
+
+ foreach ($roles as $role) {
+ $rank[$role] = count($this->getRoleHierarchy($role));
+ }
+
+ asort($rank);
+
+ return key($rank);
+ }
+
+ /**
+ * Add new access rules
+ *
+ * @access public
+ * @param string $controller Controller class name
+ * @param mixed $methods List of method name or just one method
+ * @param string $role Lowest role required
+ * @return Acl
+ */
+ public function add($controller, $methods, $role)
+ {
+ if (is_array($methods)) {
+ foreach ($methods as $method) {
+ $this->addRule($controller, $method, $role);
+ }
+ } else {
+ $this->addRule($controller, $methods, $role);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add new access rule
+ *
+ * @access private
+ * @param string $controller
+ * @param string $method
+ * @param string $role
+ * @return Acl
+ */
+ private function addRule($controller, $method, $role)
+ {
+ $controller = strtolower($controller);
+ $method = strtolower($method);
+
+ if (! isset($this->map[$controller])) {
+ $this->map[$controller] = array();
+ }
+
+ $this->map[$controller][$method] = $role;
+
+ return $this;
+ }
+
+ /**
+ * Get roles that match the given controller/method
+ *
+ * @access public
+ * @param string $controller
+ * @param string $method
+ * @return boolean
+ */
+ public function getRoles($controller, $method)
+ {
+ $controller = strtolower($controller);
+ $method = strtolower($method);
+
+ foreach (array($method, '*') as $key) {
+ if (isset($this->map[$controller][$key])) {
+ return $this->getRoleHierarchy($this->map[$controller][$key]);
+ }
+ }
+
+ return $this->getRoleHierarchy($this->defaultRole);
+ }
+}
diff --git a/app/Core/Security/AuthenticationManager.php b/app/Core/Security/AuthenticationManager.php
new file mode 100644
index 00000000..b1ba76cf
--- /dev/null
+++ b/app/Core/Security/AuthenticationManager.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+use LogicException;
+use Kanboard\Core\Base;
+use Kanboard\Event\AuthFailureEvent;
+use Kanboard\Event\AuthSuccessEvent;
+
+/**
+ * Authentication Manager
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+class AuthenticationManager extends Base
+{
+ /**
+ * Event names
+ *
+ * @var string
+ */
+ const EVENT_SUCCESS = 'auth.success';
+ const EVENT_FAILURE = 'auth.failure';
+
+ /**
+ * List of authentication providers
+ *
+ * @access private
+ * @var array
+ */
+ private $providers = array();
+
+ /**
+ * Register a new authentication provider
+ *
+ * @access public
+ * @param AuthenticationProviderInterface $provider
+ * @return AuthenticationManager
+ */
+ public function register(AuthenticationProviderInterface $provider)
+ {
+ $this->providers[$provider->getName()] = $provider;
+ return $this;
+ }
+
+ /**
+ * Register a new authentication provider
+ *
+ * @access public
+ * @param string $name
+ * @return AuthenticationProviderInterface|OAuthAuthenticationProviderInterface|PasswordAuthenticationProviderInterface|PreAuthenticationProviderInterface|OAuthAuthenticationProviderInterface
+ */
+ public function getProvider($name)
+ {
+ if (! isset($this->providers[$name])) {
+ throw new LogicException('Authentication provider not found: '.$name);
+ }
+
+ return $this->providers[$name];
+ }
+
+ /**
+ * Execute providers that are able to validate the current session
+ *
+ * @access public
+ * @return boolean
+ */
+ public function checkCurrentSession()
+ {
+ if ($this->userSession->isLogged()) {
+ foreach ($this->filterProviders('SessionCheckProviderInterface') as $provider) {
+ if (! $provider->isValidSession()) {
+ $this->logger->debug('Invalidate session for '.$this->userSession->getUsername());
+ $this->sessionStorage->flush();
+ $this->preAuthentication();
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Execute pre-authentication providers
+ *
+ * @access public
+ * @return boolean
+ */
+ public function preAuthentication()
+ {
+ foreach ($this->filterProviders('PreAuthenticationProviderInterface') as $provider) {
+ if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) {
+ $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName()));
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Execute username/password authentication providers
+ *
+ * @access public
+ * @param string $username
+ * @param string $password
+ * @param boolean $fireEvent
+ * @return boolean
+ */
+ public function passwordAuthentication($username, $password, $fireEvent = true)
+ {
+ foreach ($this->filterProviders('PasswordAuthenticationProviderInterface') as $provider) {
+ $provider->setUsername($username);
+ $provider->setPassword($password);
+
+ if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) {
+ if ($fireEvent) {
+ $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName()));
+ }
+
+ return true;
+ }
+ }
+
+ if ($fireEvent) {
+ $this->dispatcher->dispatch(self::EVENT_FAILURE, new AuthFailureEvent($username));
+ }
+
+ return false;
+ }
+
+ /**
+ * Perform OAuth2 authentication
+ *
+ * @access public
+ * @param string $name
+ * @return boolean
+ */
+ public function oauthAuthentication($name)
+ {
+ $provider = $this->getProvider($name);
+
+ if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) {
+ $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName()));
+ return true;
+ }
+
+ $this->dispatcher->dispatch(self::EVENT_FAILURE, new AuthFailureEvent);
+
+ return false;
+ }
+
+ /**
+ * Get the last Post-Authentication provider
+ *
+ * @access public
+ * @return PostAuthenticationProviderInterface
+ */
+ public function getPostAuthenticationProvider()
+ {
+ $providers = $this->filterProviders('PostAuthenticationProviderInterface');
+
+ if (empty($providers)) {
+ throw new LogicException('You must have at least one Post-Authentication Provider configured');
+ }
+
+ return array_pop($providers);
+ }
+
+ /**
+ * Filter registered providers by interface type
+ *
+ * @access private
+ * @param string $interface
+ * @return array
+ */
+ private function filterProviders($interface)
+ {
+ $interface = '\Kanboard\Core\Security\\'.$interface;
+
+ return array_filter($this->providers, function(AuthenticationProviderInterface $provider) use ($interface) {
+ return is_a($provider, $interface);
+ });
+ }
+}
diff --git a/app/Core/Security/AuthenticationProviderInterface.php b/app/Core/Security/AuthenticationProviderInterface.php
new file mode 100644
index 00000000..828e272c
--- /dev/null
+++ b/app/Core/Security/AuthenticationProviderInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Authentication Provider Interface
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+interface AuthenticationProviderInterface
+{
+ /**
+ * Get authentication provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Authenticate the user
+ *
+ * @access public
+ * @return boolean
+ */
+ public function authenticate();
+}
diff --git a/app/Core/Security/Authorization.php b/app/Core/Security/Authorization.php
new file mode 100644
index 00000000..980db048
--- /dev/null
+++ b/app/Core/Security/Authorization.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Authorization Handler
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+class Authorization
+{
+ /**
+ * Access Map
+ *
+ * @access private
+ * @var AccessMap
+ */
+ private $accessMap;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param AccessMap $accessMap
+ */
+ public function __construct(AccessMap $accessMap)
+ {
+ $this->accessMap = $accessMap;
+ }
+
+ /**
+ * Check if the given role is allowed to access to the specified resource
+ *
+ * @access public
+ * @param string $controller
+ * @param string $method
+ * @param string $role
+ * @return boolean
+ */
+ public function isAllowed($controller, $method, $role)
+ {
+ $roles = $this->accessMap->getRoles($controller, $method);
+ return in_array($role, $roles);
+ }
+}
diff --git a/app/Core/Security/OAuthAuthenticationProviderInterface.php b/app/Core/Security/OAuthAuthenticationProviderInterface.php
new file mode 100644
index 00000000..c32339e0
--- /dev/null
+++ b/app/Core/Security/OAuthAuthenticationProviderInterface.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * OAuth2 Authentication Provider Interface
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+interface OAuthAuthenticationProviderInterface extends AuthenticationProviderInterface
+{
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return UserProviderInterface
+ */
+ public function getUser();
+
+ /**
+ * Unlink user
+ *
+ * @access public
+ * @param integer $userId
+ * @return bool
+ */
+ public function unlink($userId);
+
+ /**
+ * Get configured OAuth2 service
+ *
+ * @access public
+ * @return Kanboard\Core\Http\OAuth2
+ */
+ public function getService();
+
+ /**
+ * Set OAuth2 code
+ *
+ * @access public
+ * @param string $code
+ * @return OAuthAuthenticationProviderInterface
+ */
+ public function setCode($code);
+}
diff --git a/app/Core/Security/PasswordAuthenticationProviderInterface.php b/app/Core/Security/PasswordAuthenticationProviderInterface.php
new file mode 100644
index 00000000..918a4aec
--- /dev/null
+++ b/app/Core/Security/PasswordAuthenticationProviderInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Password Authentication Provider Interface
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+interface PasswordAuthenticationProviderInterface extends AuthenticationProviderInterface
+{
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return UserProviderInterface
+ */
+ public function getUser();
+
+ /**
+ * Set username
+ *
+ * @access public
+ * @param string $username
+ */
+ public function setUsername($username);
+
+ /**
+ * Set password
+ *
+ * @access public
+ * @param string $password
+ */
+ public function setPassword($password);
+}
diff --git a/app/Core/Security/PostAuthenticationProviderInterface.php b/app/Core/Security/PostAuthenticationProviderInterface.php
new file mode 100644
index 00000000..3f628bb0
--- /dev/null
+++ b/app/Core/Security/PostAuthenticationProviderInterface.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Post Authentication Provider Interface
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+interface PostAuthenticationProviderInterface extends AuthenticationProviderInterface
+{
+ /**
+ * Called only one time before to prompt the user for pin code
+ *
+ * @access public
+ */
+ public function beforeCode();
+
+ /**
+ * Set user pin-code
+ *
+ * @access public
+ * @param string $code
+ */
+ public function setCode($code);
+
+ /**
+ * Generate secret if necessary
+ *
+ * @access public
+ * @return string
+ */
+ public function generateSecret();
+
+ /**
+ * Set secret token (fetched from user profile)
+ *
+ * @access public
+ * @param string $secret
+ */
+ public function setSecret($secret);
+
+ /**
+ * Get secret token (will be saved in user profile)
+ *
+ * @access public
+ * @return string
+ */
+ public function getSecret();
+
+ /**
+ * Get QR code url (empty if no QR can be provided)
+ *
+ * @access public
+ * @param string $label
+ * @return string
+ */
+ public function getQrCodeUrl($label);
+
+ /**
+ * Get key url (empty if no url can be provided)
+ *
+ * @access public
+ * @param string $label
+ * @return string
+ */
+ public function getKeyUrl($label);
+}
diff --git a/app/Core/Security/PreAuthenticationProviderInterface.php b/app/Core/Security/PreAuthenticationProviderInterface.php
new file mode 100644
index 00000000..391e8d0f
--- /dev/null
+++ b/app/Core/Security/PreAuthenticationProviderInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Pre-Authentication Provider Interface
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+interface PreAuthenticationProviderInterface extends AuthenticationProviderInterface
+{
+ /**
+ * Get user object
+ *
+ * @access public
+ * @return UserProviderInterface
+ */
+ public function getUser();
+}
diff --git a/app/Core/Security/Role.php b/app/Core/Security/Role.php
new file mode 100644
index 00000000..cb45a8af
--- /dev/null
+++ b/app/Core/Security/Role.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Role Definitions
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+class Role
+{
+ const APP_ADMIN = 'app-admin';
+ const APP_MANAGER = 'app-manager';
+ const APP_USER = 'app-user';
+ const APP_PUBLIC = 'app-public';
+
+ const PROJECT_MANAGER = 'project-manager';
+ const PROJECT_MEMBER = 'project-member';
+ const PROJECT_VIEWER = 'project-viewer';
+
+ /**
+ * Get application roles
+ *
+ * @access public
+ * @return array
+ */
+ public function getApplicationRoles()
+ {
+ return array(
+ self::APP_ADMIN => t('Administrator'),
+ self::APP_MANAGER => t('Manager'),
+ self::APP_USER => t('User'),
+ );
+ }
+
+ /**
+ * Get project roles
+ *
+ * @access public
+ * @return array
+ */
+ public function getProjectRoles()
+ {
+ return array(
+ self::PROJECT_MANAGER => t('Project Manager'),
+ self::PROJECT_MEMBER => t('Project Member'),
+ self::PROJECT_VIEWER => t('Project Viewer'),
+ );
+ }
+
+ /**
+ * Get role name
+ *
+ * @access public
+ * @param string $role
+ * @return string
+ */
+ public function getRoleName($role)
+ {
+ $roles = $this->getApplicationRoles() + $this->getProjectRoles();
+ return isset($roles[$role]) ? $roles[$role] : t('Unknown');
+ }
+}
diff --git a/app/Core/Security/SessionCheckProviderInterface.php b/app/Core/Security/SessionCheckProviderInterface.php
new file mode 100644
index 00000000..232fe1db
--- /dev/null
+++ b/app/Core/Security/SessionCheckProviderInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+/**
+ * Session Check Provider Interface
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+interface SessionCheckProviderInterface
+{
+ /**
+ * Check if the user session is valid
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isValidSession();
+}
diff --git a/app/Core/Security/Token.php b/app/Core/Security/Token.php
new file mode 100644
index 00000000..cbd784a8
--- /dev/null
+++ b/app/Core/Security/Token.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Kanboard\Core\Security;
+
+use Kanboard\Core\Base;
+
+/**
+ * Token Handler
+ *
+ * @package security
+ * @author Frederic Guillot
+ */
+class Token extends Base
+{
+ /**
+ * Generate a random token with different methods: openssl or /dev/urandom or fallback to uniqid()
+ *
+ * @static
+ * @access public
+ * @return string Random token
+ */
+ public static function getToken()
+ {
+ return bin2hex(random_bytes(30));
+ }
+
+ /**
+ * Generate and store a CSRF token in the current session
+ *
+ * @access public
+ * @return string Random token
+ */
+ public function getCSRFToken()
+ {
+ if (! isset($this->sessionStorage->csrf)) {
+ $this->sessionStorage->csrf = array();
+ }
+
+ $nonce = self::getToken();
+ $this->sessionStorage->csrf[$nonce] = true;
+
+ return $nonce;
+ }
+
+ /**
+ * Check if the token exists for the current session (a token can be used only one time)
+ *
+ * @access public
+ * @param string $token CSRF token
+ * @return bool
+ */
+ public function validateCSRFToken($token)
+ {
+ if (isset($this->sessionStorage->csrf[$token])) {
+ unset($this->sessionStorage->csrf[$token]);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Core/Session.php b/app/Core/Session.php
deleted file mode 100644
index a93131c7..00000000
--- a/app/Core/Session.php
+++ /dev/null
@@ -1,143 +0,0 @@
-<?php
-
-namespace Kanboard\Core;
-
-use ArrayAccess;
-
-/**
- * Session class
- *
- * @package core
- * @author Frederic Guillot
- */
-class Session implements ArrayAccess
-{
- /**
- * Return true if the session is open
- *
- * @static
- * @access public
- * @return boolean
- */
- public static function isOpen()
- {
- return session_id() !== '';
- }
-
- /**
- * Open a session
- *
- * @access public
- * @param string $base_path Cookie path
- */
- public function open($base_path = '/')
- {
- // HttpOnly and secure flags for session cookie
- session_set_cookie_params(
- SESSION_DURATION,
- $base_path ?: '/',
- null,
- Request::isHTTPS(),
- true
- );
-
- // Avoid session id in the URL
- ini_set('session.use_only_cookies', '1');
-
- // Enable strict mode
- if (version_compare(PHP_VERSION, '7.0.0') < 0) {
- ini_set('session.use_strict_mode', '1');
- }
-
- // Ensure session ID integrity
- ini_set('session.entropy_file', '/dev/urandom');
- ini_set('session.entropy_length', '32');
- ini_set('session.hash_bits_per_character', 6);
-
- // If the session was autostarted with session.auto_start = 1 in php.ini destroy it
- if (isset($_SESSION)) {
- session_destroy();
- }
-
- // Custom session name
- session_name('__S');
-
- // Start the session
- session_start();
-
- // Regenerate the session id to avoid session fixation issue
- if (empty($_SESSION['__validated'])) {
- session_regenerate_id(true);
- $_SESSION['__validated'] = 1;
- }
- }
-
- /**
- * Destroy the session
- *
- * @access public
- */
- public function close()
- {
- // Flush all sessions variables
- $_SESSION = array();
-
- // Destroy the session cookie
- $params = session_get_cookie_params();
-
- setcookie(
- session_name(),
- '',
- time() - 42000,
- $params['path'],
- $params['domain'],
- $params['secure'],
- $params['httponly']
- );
-
- // Destroy session data
- session_destroy();
- }
-
- /**
- * Register a flash message (success notification)
- *
- * @access public
- * @param string $message Message
- */
- public function flash($message)
- {
- $_SESSION['flash_message'] = $message;
- }
-
- /**
- * Register a flash error message (error notification)
- *
- * @access public
- * @param string $message Message
- */
- public function flashError($message)
- {
- $_SESSION['flash_error_message'] = $message;
- }
-
- public function offsetSet($offset, $value)
- {
- $_SESSION[$offset] = $value;
- }
-
- public function offsetExists($offset)
- {
- return isset($_SESSION[$offset]);
- }
-
- public function offsetUnset($offset)
- {
- unset($_SESSION[$offset]);
- }
-
- public function offsetGet($offset)
- {
- return isset($_SESSION[$offset]) ? $_SESSION[$offset] : null;
- }
-}
diff --git a/app/Core/Session/FlashMessage.php b/app/Core/Session/FlashMessage.php
new file mode 100644
index 00000000..e02d056d
--- /dev/null
+++ b/app/Core/Session/FlashMessage.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Kanboard\Core\Session;
+
+use Kanboard\Core\Base;
+
+/**
+ * Session Flash Message
+ *
+ * @package session
+ * @author Frederic Guillot
+ */
+class FlashMessage extends Base
+{
+ /**
+ * Add success message
+ *
+ * @access public
+ * @param string $message
+ */
+ public function success($message)
+ {
+ $this->setMessage('success', $message);
+ }
+
+ /**
+ * Add failure message
+ *
+ * @access public
+ * @param string $message
+ */
+ public function failure($message)
+ {
+ $this->setMessage('failure', $message);
+ }
+
+ /**
+ * Add new flash message
+ *
+ * @access public
+ * @param string $key
+ * @param string $message
+ */
+ public function setMessage($key, $message)
+ {
+ if (! isset($this->sessionStorage->flash)) {
+ $this->sessionStorage->flash = array();
+ }
+
+ $this->sessionStorage->flash[$key] = $message;
+ }
+
+ /**
+ * Get flash message
+ *
+ * @access public
+ * @param string $key
+ * @return string
+ */
+ public function getMessage($key)
+ {
+ $message = '';
+
+ if (isset($this->sessionStorage->flash[$key])) {
+ $message = $this->sessionStorage->flash[$key];
+ unset($this->sessionStorage->flash[$key]);
+ }
+
+ return $message;
+ }
+}
diff --git a/app/Core/Session/SessionManager.php b/app/Core/Session/SessionManager.php
new file mode 100644
index 00000000..4f9f2c0a
--- /dev/null
+++ b/app/Core/Session/SessionManager.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Kanboard\Core\Session;
+
+use Kanboard\Core\Base;
+
+/**
+ * Session Manager
+ *
+ * @package session
+ * @author Frederic Guillot
+ */
+class SessionManager extends Base
+{
+ /**
+ * Event names
+ *
+ * @var string
+ */
+ const EVENT_DESTROY = 'session.destroy';
+
+ /**
+ * Return true if the session is open
+ *
+ * @static
+ * @access public
+ * @return boolean
+ */
+ public static function isOpen()
+ {
+ return session_id() !== '';
+ }
+
+ /**
+ * Create a new session
+ *
+ * @access public
+ */
+ public function open()
+ {
+ $this->configure();
+
+ if (ini_get('session.auto_start') == 1) {
+ session_destroy();
+ }
+
+ session_name('KB_SID');
+ session_start();
+
+ $this->sessionStorage->setStorage($_SESSION);
+ }
+
+ /**
+ * Destroy the session
+ *
+ * @access public
+ */
+ public function close()
+ {
+ $this->dispatcher->dispatch(self::EVENT_DESTROY);
+
+ // Destroy the session cookie
+ $params = session_get_cookie_params();
+
+ setcookie(
+ session_name(),
+ '',
+ time() - 42000,
+ $params['path'],
+ $params['domain'],
+ $params['secure'],
+ $params['httponly']
+ );
+
+ session_unset();
+ session_destroy();
+ }
+
+ /**
+ * Define session settings
+ *
+ * @access private
+ */
+ private function configure()
+ {
+ // Session cookie: HttpOnly and secure flags
+ session_set_cookie_params(
+ SESSION_DURATION,
+ $this->helper->url->dir() ?: '/',
+ null,
+ $this->request->isHTTPS(),
+ true
+ );
+
+ // Avoid session id in the URL
+ ini_set('session.use_only_cookies', '1');
+ ini_set('session.use_trans_sid', '0');
+
+ // Enable strict mode
+ ini_set('session.use_strict_mode', '1');
+
+ // Better session hash
+ ini_set('session.hash_function', '1'); // 'sha512' is not compatible with FreeBSD, only MD5 '0' and SHA-1 '1' seems to work
+ ini_set('session.hash_bits_per_character', 6);
+
+ // Set an additional entropy
+ ini_set('session.entropy_file', '/dev/urandom');
+ ini_set('session.entropy_length', '256');
+ }
+}
diff --git a/app/Core/Session/SessionStorage.php b/app/Core/Session/SessionStorage.php
new file mode 100644
index 00000000..667d9253
--- /dev/null
+++ b/app/Core/Session/SessionStorage.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Kanboard\Core\Session;
+
+/**
+ * Session Storage
+ *
+ * @package session
+ * @author Frederic Guillot
+ *
+ * @property array $user
+ * @property array $flash
+ * @property array $csrf
+ * @property array $postAuthenticationValidated
+ * @property array $filters
+ * @property string $redirectAfterLogin
+ * @property string $captcha
+ * @property string $commentSorting
+ * @property bool $hasSubtaskInProgress
+ * @property bool $hasRememberMe
+ * @property bool $boardCollapsed
+ * @property bool $twoFactorBeforeCodeCalled
+ * @property string $twoFactorSecret
+ */
+class SessionStorage
+{
+ /**
+ * Pointer to external storage
+ *
+ * @access private
+ * @var array
+ */
+ private $storage = array();
+
+ /**
+ * Set external storage
+ *
+ * @access public
+ * @param array $storage External session storage (example: $_SESSION)
+ */
+ public function setStorage(array &$storage)
+ {
+ $this->storage =& $storage;
+
+ // Load dynamically existing session variables into object properties
+ foreach ($storage as $key => $value) {
+ $this->$key = $value;
+ }
+ }
+
+ /**
+ * Get all session variables
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ $session = get_object_vars($this);
+ unset($session['storage']);
+
+ return $session;
+ }
+
+ /**
+ * Flush session data
+ *
+ * @access public
+ */
+ public function flush()
+ {
+ $session = get_object_vars($this);
+ unset($session['storage']);
+
+ foreach (array_keys($session) as $property) {
+ unset($this->$property);
+ }
+ }
+
+ /**
+ * Copy class properties to external storage
+ *
+ * @access public
+ */
+ public function __destruct()
+ {
+ $this->storage = $this->getAll();
+ }
+}
diff --git a/app/Core/Template.php b/app/Core/Template.php
index ce2884a7..8ded6f7c 100644
--- a/app/Core/Template.php
+++ b/app/Core/Template.php
@@ -19,6 +19,50 @@ class Template extends Helper
private $overrides = array();
/**
+ * Rendering start time
+ *
+ * @access private
+ * @var float
+ */
+ private $startTime = 0;
+
+ /**
+ * Total rendering time
+ *
+ * @access private
+ * @var float
+ */
+ private $renderingTime = 0;
+
+ /**
+ * Method executed before the rendering
+ *
+ * @access protected
+ * @param string $template
+ */
+ protected function beforeRender($template)
+ {
+ if (DEBUG) {
+ $this->startTime = microtime(true);
+ }
+ }
+
+ /**
+ * Method executed after the rendering
+ *
+ * @access protected
+ * @param string $template
+ */
+ protected function afterRender($template)
+ {
+ if (DEBUG) {
+ $duration = microtime(true) - $this->startTime;
+ $this->renderingTime += $duration;
+ $this->container['logger']->debug('Rendering '.$template.' in '.$duration.'s, total='.$this->renderingTime);
+ }
+ }
+
+ /**
* Render a template
*
* Example:
@@ -32,11 +76,16 @@ class Template extends Helper
*/
public function render($__template_name, array $__template_args = array())
{
- extract($__template_args);
+ $this->beforeRender($__template_name);
+ extract($__template_args);
ob_start();
include $this->getTemplateFile($__template_name);
- return ob_get_clean();
+ $html = ob_get_clean();
+
+ $this->afterRender($__template_name);
+
+ return $html;
}
/**
diff --git a/app/Core/Tool.php b/app/Core/Tool.php
index 247fda1a..edd2e609 100644
--- a/app/Core/Tool.php
+++ b/app/Core/Tool.php
@@ -39,6 +39,7 @@ class Tool
* @access public
* @param Container $container
* @param array $namespaces
+ * @return Container
*/
public static function buildDIC(Container $container, array $namespaces)
{
@@ -50,6 +51,8 @@ class Tool
};
}
}
+
+ return $container;
}
/**
diff --git a/app/Core/Translator.php b/app/Core/Translator.php
index 96a481f6..113c0dc6 100644
--- a/app/Core/Translator.php
+++ b/app/Core/Translator.php
@@ -147,32 +147,6 @@ class Translator
}
/**
- * Get a formatted datetime
- *
- * $translator->datetime('%Y-%m-%d', time());
- *
- * @access public
- * @param string $format Format defined by the strftime function
- * @param integer $timestamp Unix timestamp
- * @return string
- */
- public function datetime($format, $timestamp)
- {
- if (! $timestamp) {
- return '';
- }
-
- $format = $this->get($format, $format);
-
- if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
- $format = str_replace('%e', '%d', $format);
- $format = str_replace('%k', '%H', $format);
- }
-
- return strftime($format, (int) $timestamp);
- }
-
- /**
* Get an identifier from the translations or return the default
*
* @access public
@@ -199,8 +173,6 @@ class Translator
*/
public static function load($language, $path = self::PATH)
{
- setlocale(LC_TIME, $language.'.UTF-8', $language);
-
$filename = $path.DIRECTORY_SEPARATOR.$language.DIRECTORY_SEPARATOR.'translations.php';
if (file_exists($filename)) {
diff --git a/app/Core/User/GroupSync.php b/app/Core/User/GroupSync.php
new file mode 100644
index 00000000..573acd47
--- /dev/null
+++ b/app/Core/User/GroupSync.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Kanboard\Core\User;
+
+use Kanboard\Core\Base;
+
+/**
+ * Group Synchronization
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class GroupSync extends Base
+{
+ /**
+ * Synchronize group membership
+ *
+ * @access public
+ * @param integer $userId
+ * @param array $groupIds
+ */
+ public function synchronize($userId, array $groupIds)
+ {
+ foreach ($groupIds as $groupId) {
+ $group = $this->group->getByExternalId($groupId);
+
+ if (! empty($group) && ! $this->groupMember->isMember($group['id'], $userId)) {
+ $this->groupMember->addUser($group['id'], $userId);
+ }
+ }
+ }
+}
diff --git a/app/Core/User/UserProfile.php b/app/Core/User/UserProfile.php
new file mode 100644
index 00000000..ef325801
--- /dev/null
+++ b/app/Core/User/UserProfile.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Kanboard\Core\User;
+
+use Kanboard\Core\Base;
+
+/**
+ * User Profile
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class UserProfile extends Base
+{
+ /**
+ * Assign provider data to the local user
+ *
+ * @access public
+ * @param integer $userId
+ * @param UserProviderInterface $user
+ * @return boolean
+ */
+ public function assign($userId, UserProviderInterface $user)
+ {
+ $profile = $this->user->getById($userId);
+
+ $values = UserProperty::filterProperties($profile, UserProperty::getProperties($user));
+ $values['id'] = $userId;
+
+ if ($this->user->update($values)) {
+ $profile = array_merge($profile, $values);
+ $this->userSession->initialize($profile);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Synchronize user properties with the local database and create the user session
+ *
+ * @access public
+ * @param UserProviderInterface $user
+ * @return boolean
+ */
+ public function initialize(UserProviderInterface $user)
+ {
+ if ($user->getInternalId()) {
+ $profile = $this->user->getById($user->getInternalId());
+ } elseif ($user->getExternalIdColumn() && $user->getExternalId()) {
+ $profile = $this->userSync->synchronize($user);
+ $this->groupSync->synchronize($profile['id'], $user->getExternalGroupIds());
+ }
+
+ if (! empty($profile) && $profile['is_active'] == 1) {
+ $this->userSession->initialize($profile);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Core/User/UserProperty.php b/app/Core/User/UserProperty.php
new file mode 100644
index 00000000..f8b08a3d
--- /dev/null
+++ b/app/Core/User/UserProperty.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Kanboard\Core\User;
+
+/**
+ * User Property
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class UserProperty
+{
+ /**
+ * Get filtered user properties from user provider
+ *
+ * @static
+ * @access public
+ * @param UserProviderInterface $user
+ * @return array
+ */
+ public static function getProperties(UserProviderInterface $user)
+ {
+ $properties = array(
+ 'username' => $user->getUsername(),
+ 'name' => $user->getName(),
+ 'email' => $user->getEmail(),
+ 'role' => $user->getRole(),
+ $user->getExternalIdColumn() => $user->getExternalId(),
+ );
+
+ $properties = array_merge($properties, $user->getExtraAttributes());
+
+ return array_filter($properties, array(__NAMESPACE__.'\UserProperty', 'isNotEmptyValue'));
+ }
+
+ /**
+ * Filter user properties compared to existing user profile
+ *
+ * @static
+ * @access public
+ * @param array $profile
+ * @param array $properties
+ * @return array
+ */
+ public static function filterProperties(array $profile, array $properties)
+ {
+ $values = array();
+
+ foreach ($properties as $property => $value) {
+ if (array_key_exists($property, $profile) && ! self::isNotEmptyValue($profile[$property])) {
+ $values[$property] = $value;
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Check if a value is not empty
+ *
+ * @static
+ * @access public
+ * @param string $value
+ * @return boolean
+ */
+ public static function isNotEmptyValue($value)
+ {
+ return $value !== null && $value !== '';
+ }
+}
diff --git a/app/Core/User/UserProviderInterface.php b/app/Core/User/UserProviderInterface.php
new file mode 100644
index 00000000..07e01f42
--- /dev/null
+++ b/app/Core/User/UserProviderInterface.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Kanboard\Core\User;
+
+/**
+ * User Provider Interface
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+interface UserProviderInterface
+{
+ /**
+ * Return true to allow automatic user creation
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isUserCreationAllowed();
+
+ /**
+ * Get external id column name
+ *
+ * Example: google_id, github_id, gitlab_id...
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalIdColumn();
+
+ /**
+ * Get internal id
+ *
+ * If a value is returned the user properties won't be updated in the local database
+ *
+ * @access public
+ * @return integer
+ */
+ public function getInternalId();
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId();
+
+ /**
+ * Get user role
+ *
+ * Return an empty string to not override role stored in the database
+ *
+ * @access public
+ * @return string
+ */
+ public function getRole();
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername();
+
+ /**
+ * Get user full name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Get user email
+ *
+ * @access public
+ * @return string
+ */
+ public function getEmail();
+
+ /**
+ * Get external group ids
+ *
+ * A synchronization is done at login time,
+ * the user will be member of those groups if they exists in the database
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getExternalGroupIds();
+
+ /**
+ * Get extra user attributes
+ *
+ * Example: is_ldap_user, disable_login_form, notifications_enabled...
+ *
+ * @access public
+ * @return array
+ */
+ public function getExtraAttributes();
+}
diff --git a/app/Core/User/UserSession.php b/app/Core/User/UserSession.php
new file mode 100644
index 00000000..534e5192
--- /dev/null
+++ b/app/Core/User/UserSession.php
@@ -0,0 +1,204 @@
+<?php
+
+namespace Kanboard\Core\User;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Security\Role;
+
+/**
+ * User Session
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class UserSession extends Base
+{
+ /**
+ * Update user session
+ *
+ * @access public
+ * @param array $user
+ */
+ public function initialize(array $user)
+ {
+ foreach (array('password', 'is_admin', 'is_project_admin', 'twofactor_secret') as $column) {
+ if (isset($user[$column])) {
+ unset($user[$column]);
+ }
+ }
+
+ $user['id'] = (int) $user['id'];
+ $user['is_ldap_user'] = isset($user['is_ldap_user']) ? (bool) $user['is_ldap_user'] : false;
+ $user['twofactor_activated'] = isset($user['twofactor_activated']) ? (bool) $user['twofactor_activated'] : false;
+
+ $this->sessionStorage->user = $user;
+ $this->sessionStorage->postAuthenticationValidated = false;
+ }
+
+ /**
+ * Get user application role
+ *
+ * @access public
+ * @return string
+ */
+ public function getRole()
+ {
+ return $this->sessionStorage->user['role'];
+ }
+
+ /**
+ * Return true if the user has validated the 2FA key
+ *
+ * @access public
+ * @return bool
+ */
+ public function isPostAuthenticationValidated()
+ {
+ return isset($this->sessionStorage->postAuthenticationValidated) && $this->sessionStorage->postAuthenticationValidated === true;
+ }
+
+ /**
+ * Validate 2FA for the current session
+ *
+ * @access public
+ */
+ public function validatePostAuthentication()
+ {
+ $this->sessionStorage->postAuthenticationValidated = true;
+ }
+
+ /**
+ * Return true if the user has 2FA enabled
+ *
+ * @access public
+ * @return bool
+ */
+ public function hasPostAuthentication()
+ {
+ return isset($this->sessionStorage->user['twofactor_activated']) && $this->sessionStorage->user['twofactor_activated'] === true;
+ }
+
+ /**
+ * Disable 2FA for the current session
+ *
+ * @access public
+ */
+ public function disablePostAuthentication()
+ {
+ $this->sessionStorage->user['twofactor_activated'] = false;
+ }
+
+ /**
+ * Return true if the logged user is admin
+ *
+ * @access public
+ * @return bool
+ */
+ public function isAdmin()
+ {
+ return isset($this->sessionStorage->user['role']) && $this->sessionStorage->user['role'] === Role::APP_ADMIN;
+ }
+
+ /**
+ * Get the connected user id
+ *
+ * @access public
+ * @return integer
+ */
+ public function getId()
+ {
+ return isset($this->sessionStorage->user['id']) ? (int) $this->sessionStorage->user['id'] : 0;
+ }
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername()
+ {
+ return isset($this->sessionStorage->user['username']) ? $this->sessionStorage->user['username'] : '';
+ }
+
+ /**
+ * Check is the user is connected
+ *
+ * @access public
+ * @return bool
+ */
+ public function isLogged()
+ {
+ return isset($this->sessionStorage->user) && ! empty($this->sessionStorage->user);
+ }
+
+ /**
+ * Get project filters from the session
+ *
+ * @access public
+ * @param integer $project_id
+ * @return string
+ */
+ public function getFilters($project_id)
+ {
+ return ! empty($this->sessionStorage->filters[$project_id]) ? $this->sessionStorage->filters[$project_id] : 'status:open';
+ }
+
+ /**
+ * Save project filters in the session
+ *
+ * @access public
+ * @param integer $project_id
+ * @param string $filters
+ */
+ public function setFilters($project_id, $filters)
+ {
+ $this->sessionStorage->filters[$project_id] = $filters;
+ }
+
+ /**
+ * Is board collapsed or expanded
+ *
+ * @access public
+ * @param integer $project_id
+ * @return boolean
+ */
+ public function isBoardCollapsed($project_id)
+ {
+ return ! empty($this->sessionStorage->boardCollapsed[$project_id]) ? $this->sessionStorage->boardCollapsed[$project_id] : false;
+ }
+
+ /**
+ * Set board display mode
+ *
+ * @access public
+ * @param integer $project_id
+ * @param boolean $is_collapsed
+ */
+ public function setBoardDisplayMode($project_id, $is_collapsed)
+ {
+ $this->sessionStorage->boardCollapsed[$project_id] = $is_collapsed;
+ }
+
+ /**
+ * Set comments sorting
+ *
+ * @access public
+ * @param string $order
+ */
+ public function setCommentSorting($order)
+ {
+ $this->sessionStorage->commentSorting = $order;
+ }
+
+ /**
+ * Get comments sorting direction
+ *
+ * @access public
+ * @return string
+ */
+ public function getCommentSorting()
+ {
+ return empty($this->sessionStorage->commentSorting) ? 'ASC' : $this->sessionStorage->commentSorting;
+ }
+}
diff --git a/app/Core/User/UserSync.php b/app/Core/User/UserSync.php
new file mode 100644
index 00000000..d450a0bd
--- /dev/null
+++ b/app/Core/User/UserSync.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Kanboard\Core\User;
+
+use Kanboard\Core\Base;
+
+/**
+ * User Synchronization
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class UserSync extends Base
+{
+ /**
+ * Synchronize user profile
+ *
+ * @access public
+ * @param UserProviderInterface $user
+ * @return array
+ */
+ public function synchronize(UserProviderInterface $user)
+ {
+ $profile = $this->user->getByExternalId($user->getExternalIdColumn(), $user->getExternalId());
+ $properties = UserProperty::getProperties($user);
+
+ if (! empty($profile)) {
+ $profile = $this->updateUser($profile, $properties);
+ } elseif ($user->isUserCreationAllowed()) {
+ $profile = $this->createUser($user, $properties);
+ }
+
+ return $profile;
+ }
+
+ /**
+ * Update user profile
+ *
+ * @access public
+ * @param array $profile
+ * @param array $properties
+ * @return array
+ */
+ private function updateUser(array $profile, array $properties)
+ {
+ $values = UserProperty::filterProperties($profile, $properties);
+
+ if (! empty($values)) {
+ $values['id'] = $profile['id'];
+ $result = $this->user->update($values);
+ return $result ? array_merge($profile, $properties) : $profile;
+ }
+
+ return $profile;
+ }
+
+ /**
+ * Create user
+ *
+ * @access public
+ * @param UserProviderInterface $user
+ * @param array $properties
+ * @return array
+ */
+ private function createUser(UserProviderInterface $user, array $properties)
+ {
+ $id = $this->user->create($properties);
+
+ if ($id === false) {
+ $this->logger->error('Unable to create user profile: '.$user->getExternalId());
+ return array();
+ }
+
+ return $this->user->getById($id);
+ }
+}
diff --git a/app/Event/AuthEvent.php b/app/Event/AuthEvent.php
deleted file mode 100644
index 7cbced83..00000000
--- a/app/Event/AuthEvent.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-namespace Kanboard\Event;
-
-use Symfony\Component\EventDispatcher\Event as BaseEvent;
-
-class AuthEvent extends BaseEvent
-{
- private $auth_name;
- private $user_id;
-
- public function __construct($auth_name, $user_id)
- {
- $this->auth_name = $auth_name;
- $this->user_id = $user_id;
- }
-
- public function getUserId()
- {
- return $this->user_id;
- }
-
- public function getAuthType()
- {
- return $this->auth_name;
- }
-}
diff --git a/app/Event/AuthFailureEvent.php b/app/Event/AuthFailureEvent.php
new file mode 100644
index 00000000..225ac04a
--- /dev/null
+++ b/app/Event/AuthFailureEvent.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Kanboard\Event;
+
+use Symfony\Component\EventDispatcher\Event as BaseEvent;
+
+/**
+ * Authentication Failure Event
+ *
+ * @package event
+ * @author Frederic Guillot
+ */
+class AuthFailureEvent extends BaseEvent
+{
+ /**
+ * Username
+ *
+ * @access private
+ * @var string
+ */
+ private $username = '';
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param string $username
+ */
+ public function __construct($username = '')
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername()
+ {
+ return $this->username;
+ }
+}
diff --git a/app/Event/AuthSuccessEvent.php b/app/Event/AuthSuccessEvent.php
new file mode 100644
index 00000000..38323e82
--- /dev/null
+++ b/app/Event/AuthSuccessEvent.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Kanboard\Event;
+
+use Symfony\Component\EventDispatcher\Event as BaseEvent;
+
+/**
+ * Authentication Success Event
+ *
+ * @package event
+ * @author Frederic Guillot
+ */
+class AuthSuccessEvent extends BaseEvent
+{
+ /**
+ * Authentication provider name
+ *
+ * @access private
+ * @var string
+ */
+ private $authType;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param string $authType
+ */
+ public function __construct($authType)
+ {
+ $this->authType = $authType;
+ }
+
+ /**
+ * Get authentication type
+ *
+ * @return string
+ */
+ public function getAuthType()
+ {
+ return $this->authType;
+ }
+}
diff --git a/app/Event/GenericEvent.php b/app/Event/GenericEvent.php
index 1129fd16..94a51479 100644
--- a/app/Event/GenericEvent.php
+++ b/app/Event/GenericEvent.php
@@ -7,7 +7,7 @@ use Symfony\Component\EventDispatcher\Event as BaseEvent;
class GenericEvent extends BaseEvent implements ArrayAccess
{
- private $container = array();
+ protected $container = array();
public function __construct(array $values = array())
{
diff --git a/app/Event/TaskListEvent.php b/app/Event/TaskListEvent.php
new file mode 100644
index 00000000..9be1a7d9
--- /dev/null
+++ b/app/Event/TaskListEvent.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Kanboard\Event;
+
+class TaskListEvent extends GenericEvent
+{
+ public function setTasks(array &$tasks)
+ {
+ $this->container['tasks'] =& $tasks;
+ }
+}
diff --git a/app/ExternalLink/AttachmentLink.php b/app/ExternalLink/AttachmentLink.php
new file mode 100644
index 00000000..5a0d1344
--- /dev/null
+++ b/app/ExternalLink/AttachmentLink.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Kanboard\ExternalLink;
+
+use Kanboard\Core\ExternalLink\ExternalLinkInterface;
+
+/**
+ * Attachment Link
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+class AttachmentLink extends BaseLink implements ExternalLinkInterface
+{
+ /**
+ * Get link title
+ *
+ * @access public
+ * @return string
+ */
+ public function getTitle()
+ {
+ $path = parse_url($this->url, PHP_URL_PATH);
+ return basename($path);
+ }
+}
diff --git a/app/ExternalLink/AttachmentLinkProvider.php b/app/ExternalLink/AttachmentLinkProvider.php
new file mode 100644
index 00000000..df27284f
--- /dev/null
+++ b/app/ExternalLink/AttachmentLinkProvider.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Kanboard\ExternalLink;
+
+use Kanboard\Core\ExternalLink\ExternalLinkProviderInterface;
+
+/**
+ * Attachment Link Provider
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+class AttachmentLinkProvider extends BaseLinkProvider implements ExternalLinkProviderInterface
+{
+ /**
+ * File extensions that are not attachments
+ *
+ * @access protected
+ * @var array
+ */
+ protected $extensions = array(
+ 'html',
+ 'htm',
+ 'xhtml',
+ 'php',
+ 'jsp',
+ 'do',
+ 'action',
+ 'asp',
+ 'aspx',
+ 'cgi',
+ );
+
+ /**
+ * Get provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return t('Attachment');
+ }
+
+ /**
+ * Get link type
+ *
+ * @access public
+ * @return string
+ */
+ public function getType()
+ {
+ return 'attachment';
+ }
+
+ /**
+ * Get a dictionary of supported dependency types by the provider
+ *
+ * @access public
+ * @return array
+ */
+ public function getDependencies()
+ {
+ return array(
+ 'related' => t('Related'),
+ );
+ }
+
+ /**
+ * Return true if the provider can parse correctly the user input
+ *
+ * @access public
+ * @return boolean
+ */
+ public function match()
+ {
+ if (preg_match('/^https?:\/\/.*\.([^\/]+)$/', $this->userInput, $matches)) {
+ return $this->isValidExtension($matches[1]);
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the link found with the properties
+ *
+ * @access public
+ * @return ExternalLinkInterface
+ */
+ public function getLink()
+ {
+ $link = new AttachmentLink($this->container);
+ $link->setUrl($this->userInput);
+
+ return $link;
+ }
+
+ /**
+ * Check file extension
+ *
+ * @access protected
+ * @param string $extension
+ * @return boolean
+ */
+ protected function isValidExtension($extension)
+ {
+ $extension = strtolower($extension);
+
+ foreach ($this->extensions as $ext) {
+ if ($extension === $ext) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/app/ExternalLink/BaseLink.php b/app/ExternalLink/BaseLink.php
new file mode 100644
index 00000000..08693ae7
--- /dev/null
+++ b/app/ExternalLink/BaseLink.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Kanboard\ExternalLink;
+
+use Kanboard\Core\Base;
+
+/**
+ * Base Link
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+abstract class BaseLink extends Base
+{
+ /**
+ * URL
+ *
+ * @access protected
+ * @var string
+ */
+ protected $url = '';
+
+ /**
+ * Get link URL
+ *
+ * @access public
+ * @return string
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set link URL
+ *
+ * @access public
+ * @param string $url
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+ }
+}
diff --git a/app/ExternalLink/BaseLinkProvider.php b/app/ExternalLink/BaseLinkProvider.php
new file mode 100644
index 00000000..749cda94
--- /dev/null
+++ b/app/ExternalLink/BaseLinkProvider.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Kanboard\ExternalLink;
+
+use Kanboard\Core\Base;
+
+/**
+ * Base Link Provider
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+abstract class BaseLinkProvider extends Base
+{
+ /**
+ * User input
+ *
+ * @access protected
+ * @var string
+ */
+ protected $userInput = '';
+
+ /**
+ * Set text entered by the user
+ *
+ * @access public
+ * @param string $input
+ */
+ public function setUserTextInput($input)
+ {
+ $this->userInput = trim($input);
+ }
+}
diff --git a/app/ExternalLink/WebLink.php b/app/ExternalLink/WebLink.php
new file mode 100644
index 00000000..9338ca42
--- /dev/null
+++ b/app/ExternalLink/WebLink.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Kanboard\ExternalLink;
+
+use Kanboard\Core\ExternalLink\ExternalLinkInterface;
+
+/**
+ * Web Link
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+class WebLink extends BaseLink implements ExternalLinkInterface
+{
+ /**
+ * Get link title
+ *
+ * @access public
+ * @return string
+ */
+ public function getTitle()
+ {
+ $html = $this->httpClient->get($this->url);
+
+ if (preg_match('/<title>(.*)<\/title>/siU', $html, $matches)) {
+ return trim($matches[1]);
+ }
+
+ $components = parse_url($this->url);
+
+ if (! empty($components['host']) && ! empty($components['path'])) {
+ return $components['host'].$components['path'];
+ }
+
+ return t('Title not found');
+ }
+}
diff --git a/app/ExternalLink/WebLinkProvider.php b/app/ExternalLink/WebLinkProvider.php
new file mode 100644
index 00000000..ea6dc132
--- /dev/null
+++ b/app/ExternalLink/WebLinkProvider.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Kanboard\ExternalLink;
+
+use Kanboard\Core\ExternalLink\ExternalLinkProviderInterface;
+
+/**
+ * Web Link Provider
+ *
+ * @package externalLink
+ * @author Frederic Guillot
+ */
+class WebLinkProvider extends BaseLinkProvider implements ExternalLinkProviderInterface
+{
+ /**
+ * Get provider name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return t('Web Link');
+ }
+
+ /**
+ * Get link type
+ *
+ * @access public
+ * @return string
+ */
+ public function getType()
+ {
+ return 'weblink';
+ }
+
+ /**
+ * Get a dictionary of supported dependency types by the provider
+ *
+ * @access public
+ * @return array
+ */
+ public function getDependencies()
+ {
+ return array(
+ 'related' => t('Related'),
+ );
+ }
+
+ /**
+ * Return true if the provider can parse correctly the user input
+ *
+ * @access public
+ * @return boolean
+ */
+ public function match()
+ {
+ $startWithHttp = strpos($this->userInput, 'http://') === 0 || strpos($this->userInput, 'https://') === 0;
+ $validUrl = filter_var($this->userInput, FILTER_VALIDATE_URL);
+
+ return $startWithHttp && $validUrl;
+ }
+
+ /**
+ * Get the link found with the properties
+ *
+ * @access public
+ * @return ExternalLinkInterface
+ */
+ public function getLink()
+ {
+ $link = new WebLink($this->container);
+ $link->setUrl($this->userInput);
+
+ return $link;
+ }
+}
diff --git a/app/Formatter/GroupAutoCompleteFormatter.php b/app/Formatter/GroupAutoCompleteFormatter.php
new file mode 100644
index 00000000..7023e367
--- /dev/null
+++ b/app/Formatter/GroupAutoCompleteFormatter.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Kanboard\Formatter;
+
+/**
+ * Autocomplete formatter for groups
+ *
+ * @package formatter
+ * @author Frederic Guillot
+ */
+class GroupAutoCompleteFormatter implements FormatterInterface
+{
+ /**
+ * Groups found
+ *
+ * @access private
+ * @var array
+ */
+ private $groups;
+
+ /**
+ * Format groups for the ajax autocompletion
+ *
+ * @access public
+ * @param array $groups
+ * @return GroupAutoCompleteFormatter
+ */
+ public function setGroups(array $groups)
+ {
+ $this->groups = $groups;
+ return $this;
+ }
+
+ /**
+ * Format groups for the ajax autocompletion
+ *
+ * @access public
+ * @return array
+ */
+ public function format()
+ {
+ $result = array();
+
+ foreach ($this->groups as $group) {
+ $result[] = array(
+ 'id' => $group->getInternalId(),
+ 'external_id' => $group->getExternalId(),
+ 'value' => $group->getName(),
+ 'label' => $group->getName(),
+ );
+ }
+
+ return $result;
+ }
+}
diff --git a/app/Formatter/ProjectGanttFormatter.php b/app/Formatter/ProjectGanttFormatter.php
index 17496088..4f73e217 100644
--- a/app/Formatter/ProjectGanttFormatter.php
+++ b/app/Formatter/ProjectGanttFormatter.php
@@ -79,7 +79,7 @@ class ProjectGanttFormatter extends Project implements FormatterInterface
'gantt_link' => $this->helper->url->href('gantt', 'project', array('project_id' => $project['id'])),
'color' => $color,
'not_defined' => empty($project['start_date']) || empty($project['end_date']),
- 'users' => $this->projectPermission->getProjectUsers($project['id']),
+ 'users' => $this->projectUserRole->getAllUsersGroupedByRole($project['id']),
);
}
diff --git a/app/Formatter/TaskFilterGanttFormatter.php b/app/Formatter/TaskFilterGanttFormatter.php
index 08059d4c..a4eef1ee 100644
--- a/app/Formatter/TaskFilterGanttFormatter.php
+++ b/app/Formatter/TaskFilterGanttFormatter.php
@@ -47,7 +47,7 @@ class TaskFilterGanttFormatter extends TaskFilter implements FormatterInterface
private function formatTask(array $task)
{
if (! isset($this->columns[$task['project_id']])) {
- $this->columns[$task['project_id']] = $this->board->getColumnsList($task['project_id']);
+ $this->columns[$task['project_id']] = $this->column->getList($task['project_id']);
}
$start = $task['date_started'] ?: time();
diff --git a/app/Formatter/UserFilterAutoCompleteFormatter.php b/app/Formatter/UserFilterAutoCompleteFormatter.php
new file mode 100644
index 00000000..b98e0d69
--- /dev/null
+++ b/app/Formatter/UserFilterAutoCompleteFormatter.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Kanboard\Formatter;
+
+use Kanboard\Model\User;
+use Kanboard\Model\UserFilter;
+
+/**
+ * Autocomplete formatter for user filter
+ *
+ * @package formatter
+ * @author Frederic Guillot
+ */
+class UserFilterAutoCompleteFormatter extends UserFilter implements FormatterInterface
+{
+ /**
+ * Format the tasks for the ajax autocompletion
+ *
+ * @access public
+ * @return array
+ */
+ public function format()
+ {
+ $users = $this->query->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name')->findAll();
+
+ foreach ($users as &$user) {
+ $user['value'] = $user['username'].' (#'.$user['id'].')';
+
+ if (empty($user['name'])) {
+ $user['label'] = $user['username'];
+ } else {
+ $user['label'] = $user['name'].' ('.$user['username'].')';
+ }
+ }
+
+ return $users;
+ }
+}
diff --git a/app/Group/DatabaseBackendGroupProvider.php b/app/Group/DatabaseBackendGroupProvider.php
new file mode 100644
index 00000000..6dbaa43c
--- /dev/null
+++ b/app/Group/DatabaseBackendGroupProvider.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Kanboard\Group;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Group\GroupBackendProviderInterface;
+
+/**
+ * Database Backend Group Provider
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+class DatabaseBackendGroupProvider extends Base implements GroupBackendProviderInterface
+{
+ /**
+ * Find a group from a search query
+ *
+ * @access public
+ * @param string $input
+ * @return DatabaseGroupProvider[]
+ */
+ public function find($input)
+ {
+ $result = array();
+ $groups = $this->group->search($input);
+
+ foreach ($groups as $group) {
+ $result[] = new DatabaseGroupProvider($group);
+ }
+
+ return $result;
+ }
+}
diff --git a/app/Group/DatabaseGroupProvider.php b/app/Group/DatabaseGroupProvider.php
new file mode 100644
index 00000000..430121a3
--- /dev/null
+++ b/app/Group/DatabaseGroupProvider.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Kanboard\Group;
+
+use Kanboard\Core\Group\GroupProviderInterface;
+
+/**
+ * Database Group Provider
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+class DatabaseGroupProvider implements GroupProviderInterface
+{
+ /**
+ * Group properties
+ *
+ * @access private
+ * @var array
+ */
+ private $group = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param array $group
+ */
+ public function __construct(array $group)
+ {
+ $this->group = $group;
+ }
+
+ /**
+ * Get internal id
+ *
+ * @access public
+ * @return integer
+ */
+ public function getInternalId()
+ {
+ return (int) $this->group['id'];
+ }
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId()
+ {
+ return '';
+ }
+
+ /**
+ * Get group name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->group['name'];
+ }
+}
diff --git a/app/Group/LdapBackendGroupProvider.php b/app/Group/LdapBackendGroupProvider.php
new file mode 100644
index 00000000..cad732c4
--- /dev/null
+++ b/app/Group/LdapBackendGroupProvider.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Kanboard\Group;
+
+use LogicException;
+use Kanboard\Core\Base;
+use Kanboard\Core\Group\GroupBackendProviderInterface;
+use Kanboard\Core\Ldap\Client as LdapClient;
+use Kanboard\Core\Ldap\ClientException as LdapException;
+use Kanboard\Core\Ldap\Group as LdapGroup;
+
+/**
+ * LDAP Backend Group Provider
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+class LdapBackendGroupProvider extends Base implements GroupBackendProviderInterface
+{
+ /**
+ * Find a group from a search query
+ *
+ * @access public
+ * @param string $input
+ * @return LdapGroupProvider[]
+ */
+ public function find($input)
+ {
+ try {
+ $ldap = LdapClient::connect();
+ return LdapGroup::getGroups($ldap, $this->getLdapGroupPattern($input));
+
+ } catch (LdapException $e) {
+ $this->logger->error($e->getMessage());
+ return array();
+ }
+ }
+
+ /**
+ * Get LDAP group pattern
+ *
+ * @access public
+ * @param string $input
+ * @return string
+ */
+ public function getLdapGroupPattern($input)
+ {
+ if (LDAP_GROUP_FILTER === '') {
+ throw new LogicException('LDAP group filter empty, check the parameter LDAP_GROUP_FILTER');
+ }
+
+ return sprintf(LDAP_GROUP_FILTER, $input);
+ }
+}
diff --git a/app/Group/LdapGroupProvider.php b/app/Group/LdapGroupProvider.php
new file mode 100644
index 00000000..b497d485
--- /dev/null
+++ b/app/Group/LdapGroupProvider.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Kanboard\Group;
+
+use Kanboard\Core\Group\GroupProviderInterface;
+
+/**
+ * LDAP Group Provider
+ *
+ * @package group
+ * @author Frederic Guillot
+ */
+class LdapGroupProvider implements GroupProviderInterface
+{
+ /**
+ * Group DN
+ *
+ * @access private
+ * @var string
+ */
+ private $dn = '';
+
+ /**
+ * Group Name
+ *
+ * @access private
+ * @var string
+ */
+ private $name = '';
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param string $dn
+ * @param string $name
+ */
+ public function __construct($dn, $name)
+ {
+ $this->dn = $dn;
+ $this->name = $name;
+ }
+
+ /**
+ * Get internal id
+ *
+ * @access public
+ * @return integer
+ */
+ public function getInternalId()
+ {
+ return '';
+ }
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId()
+ {
+ return $this->dn;
+ }
+
+ /**
+ * Get group name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+}
diff --git a/app/Helper/App.php b/app/Helper/App.php
index 19801fa8..79afa5b9 100644
--- a/app/Helper/App.php
+++ b/app/Helper/App.php
@@ -2,15 +2,65 @@
namespace Kanboard\Helper;
+use Kanboard\Core\Base;
+
/**
* Application helpers
*
* @package helper
* @author Frederic Guillot
*/
-class App extends \Kanboard\Core\Base
+class App extends Base
{
/**
+ * Get config variable
+ *
+ * @access public
+ * @param string $param
+ * @param mixed $default_value
+ * @return mixed
+ */
+ public function config($param, $default_value = '')
+ {
+ return $this->config->get($param, $default_value);
+ }
+
+ /**
+ * Make sidebar menu active
+ *
+ * @access public
+ * @param string $controller
+ * @param string $action
+ * @param string $plugin
+ * @return string
+ */
+ public function checkMenuSelection($controller, $action = '', $plugin = '')
+ {
+ $result = strtolower($this->getRouterController()) === strtolower($controller);
+
+ if ($result && $action !== '') {
+ $result = strtolower($this->getRouterAction()) === strtolower($action);
+ }
+
+ if ($result && $plugin !== '') {
+ $result = strtolower($this->getPluginName()) === strtolower($plugin);
+ }
+
+ return $result ? 'class="active"' : '';
+ }
+
+ /**
+ * Get plugin name from route
+ *
+ * @access public
+ * @return string
+ */
+ public function getPluginName()
+ {
+ return $this->router->getPlugin();
+ }
+
+ /**
* Get router controller
*
* @access public
@@ -62,18 +112,17 @@ class App extends \Kanboard\Core\Base
*/
public function flashMessage()
{
- $html = '';
-
- if (isset($this->session['flash_message'])) {
- $html = '<div class="alert alert-success alert-fade-out">'.$this->helper->e($this->session['flash_message']).'</div>';
- unset($this->session['flash_message']);
- unset($this->session['flash_error_message']);
- } elseif (isset($this->session['flash_error_message'])) {
- $html = '<div class="alert alert-error">'.$this->helper->e($this->session['flash_error_message']).'</div>';
- unset($this->session['flash_message']);
- unset($this->session['flash_error_message']);
+ $success_message = $this->flash->getMessage('success');
+ $failure_message = $this->flash->getMessage('failure');
+
+ if (! empty($success_message)) {
+ return '<div class="alert alert-success alert-fade-out">'.$this->helper->e($success_message).'</div>';
+ }
+
+ if (! empty($failure_message)) {
+ return '<div class="alert alert-error">'.$this->helper->e($failure_message).'</div>';
}
- return $html;
+ return '';
}
}
diff --git a/app/Helper/Dt.php b/app/Helper/Dt.php
index 78002b1b..eb3f93b3 100644
--- a/app/Helper/Dt.php
+++ b/app/Helper/Dt.php
@@ -13,6 +13,50 @@ use DateTime;
class Dt extends \Kanboard\Core\Base
{
/**
+ * Get formatted time
+ *
+ * @access public
+ * @param integer $value
+ * @return string
+ */
+ public function time($value)
+ {
+ return date($this->config->get('application_time_format', 'H:i'), $value);
+ }
+
+ /**
+ * Get formatted date
+ *
+ * @access public
+ * @param integer $value
+ * @return string
+ */
+ public function date($value)
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ if (! ctype_digit($value)) {
+ $value = strtotime($value);
+ }
+
+ return date($this->config->get('application_date_format', 'm/d/Y'), $value);
+ }
+
+ /**
+ * Get formatted datetime
+ *
+ * @access public
+ * @param integer $value
+ * @return string
+ */
+ public function datetime($value)
+ {
+ return date($this->config->get('application_datetime_format', 'm/d/Y H:i'), $value);
+ }
+
+ /**
* Get duration in seconds into human format
*
* @access public
@@ -107,6 +151,6 @@ class Dt extends \Kanboard\Core\Base
*/
public function getWeekDay($day)
{
- return dt('%A', strtotime('next Monday +'.($day - 1).' days'));
+ return date('l', strtotime('next Monday +'.($day - 1).' days'));
}
}
diff --git a/app/Helper/File.php b/app/Helper/File.php
index d2cdfc6a..b493e64f 100644
--- a/app/Helper/File.php
+++ b/app/Helper/File.php
@@ -38,19 +38,70 @@ class File extends \Kanboard\Core\Base
return 'fa-file-powerpoint-o';
case 'zip':
case 'rar':
+ case 'tar':
+ case 'bz2':
+ case 'xz':
+ case 'gz':
return 'fa-file-archive-o';
case 'mp3':
- return 'fa-audio-o';
+ return 'fa-file-audio-o';
case 'avi':
- return 'fa-video-o';
+ case 'mov':
+ return 'fa-file-video-o';
case 'php':
case 'html':
case 'css':
- return 'fa-code-o';
+ return 'fa-file-code-o';
case 'pdf':
return 'fa-file-pdf-o';
}
return 'fa-file-o';
}
+
+ /**
+ * Return the image mimetype based on the file extension
+ *
+ * @access public
+ * @param $filename
+ * @return string
+ */
+ public function getImageMimeType($filename)
+ {
+ $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+
+ switch ($extension) {
+ case 'jpeg':
+ case 'jpg':
+ return 'image/jpeg';
+ case 'png':
+ return 'image/png';
+ case 'gif':
+ return 'image/gif';
+ default:
+ return 'image/jpeg';
+ }
+ }
+
+ /**
+ * Get the preview type
+ *
+ * @access public
+ * @param string $filename
+ * @return string
+ */
+ public function getPreviewType($filename)
+ {
+ $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+
+ switch ($extension) {
+ case 'md':
+ case 'markdown':
+ return 'markdown';
+ case 'txt':
+ return 'text';
+ }
+
+ return null;
+ }
}
diff --git a/app/Helper/Form.php b/app/Helper/Form.php
index 5f19f2a8..bfd75ee3 100644
--- a/app/Helper/Form.php
+++ b/app/Helper/Form.php
@@ -2,7 +2,7 @@
namespace Kanboard\Helper;
-use Kanboard\Core\Security;
+use Kanboard\Core\Base;
/**
* Form helpers
@@ -10,7 +10,7 @@ use Kanboard\Core\Security;
* @package helper
* @author Frederic Guillot
*/
-class Form extends \Kanboard\Core\Base
+class Form extends Base
{
/**
* Hidden CSRF token field
@@ -20,7 +20,7 @@ class Form extends \Kanboard\Core\Base
*/
public function csrf()
{
- return '<input type="hidden" name="csrf_token" value="'.Security::getCSRFToken().'"/>';
+ return '<input type="hidden" name="csrf_token" value="'.$this->token->getCSRFToken().'"/>';
}
/**
diff --git a/app/Helper/Layout.php b/app/Helper/Layout.php
new file mode 100644
index 00000000..3db23920
--- /dev/null
+++ b/app/Helper/Layout.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Kanboard\Helper;
+
+use Kanboard\Core\Base;
+
+/**
+ * Layout helpers
+ *
+ * @package helper
+ * @author Frederic Guillot
+ */
+class Layout extends Base
+{
+ /**
+ * Render a template without the layout if Ajax request
+ *
+ * @access public
+ * @param string $template Template name
+ * @param array $params Template parameters
+ * @return string
+ */
+ public function app($template, array $params = array())
+ {
+ if ($this->request->isAjax()) {
+ return $this->template->render($template, $params);
+ }
+
+ if (! isset($params['no_layout']) && ! isset($params['board_selector'])) {
+ $params['board_selector'] = $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId());
+ }
+
+ return $this->template->layout($template, $params);
+ }
+
+ /**
+ * Common layout for user views
+ *
+ * @access public
+ * @param string $template Template name
+ * @param array $params Template parameters
+ * @return string
+ */
+ public function user($template, array $params)
+ {
+ if (isset($params['user'])) {
+ $params['title'] = '#'.$params['user']['id'].' '.($params['user']['name'] ?: $params['user']['username']);
+ }
+
+ return $this->subLayout('user/layout', 'user/sidebar', $template, $params);
+ }
+
+ /**
+ * Common layout for task views
+ *
+ * @access public
+ * @param string $template Template name
+ * @param array $params Template parameters
+ * @return string
+ */
+ public function task($template, array $params)
+ {
+ $params['title'] = $params['task']['title'];
+ return $this->subLayout('task/layout', 'task/sidebar', $template, $params);
+ }
+
+ /**
+ * Common layout for project views
+ *
+ * @access public
+ * @param string $template
+ * @param array $params
+ * @param string $sidebar
+ * @return string
+ */
+ public function project($template, array $params, $sidebar = 'project/sidebar')
+ {
+ if (empty($params['title'])) {
+ $params['title'] = $params['project']['name'];
+ } elseif ($params['project']['name'] !== $params['title']) {
+ $params['title'] = $params['project']['name'].' &gt; '.$params['title'];
+ }
+
+ return $this->subLayout('project/layout', $sidebar, $template, $params);
+ }
+
+ /**
+ * Common layout for project user views
+ *
+ * @access public
+ * @param string $template
+ * @param array $params
+ * @return string
+ */
+ public function projectUser($template, array $params)
+ {
+ $params['filter'] = array('user_id' => $params['user_id']);
+ return $this->subLayout('project_user/layout', 'project_user/sidebar', $template, $params);
+ }
+
+ /**
+ * Common layout for config views
+ *
+ * @access public
+ * @param string $template
+ * @param array $params
+ * @return string
+ */
+ public function config($template, array $params)
+ {
+ if (! isset($params['values'])) {
+ $params['values'] = $this->config->getAll();
+ }
+
+ if (! isset($params['errors'])) {
+ $params['errors'] = array();
+ }
+
+ return $this->subLayout('config/layout', 'config/sidebar', $template, $params);
+ }
+
+ /**
+ * Common layout for dashboard views
+ *
+ * @access public
+ * @param string $template
+ * @param array $params
+ * @return string
+ */
+ public function dashboard($template, array $params)
+ {
+ return $this->subLayout('app/layout', 'app/sidebar', $template, $params);
+ }
+
+ /**
+ * Common layout for analytic views
+ *
+ * @access public
+ * @param string $template
+ * @param array $params
+ * @return string
+ */
+ public function analytic($template, array $params)
+ {
+ return $this->subLayout('analytic/layout', 'analytic/sidebar', $template, $params);
+ }
+
+ /**
+ * Common method to generate a sublayout
+ *
+ * @access public
+ * @param string $sublayout
+ * @param string $sidebar
+ * @param string $template
+ * @param array $params
+ * @return string
+ */
+ public function subLayout($sublayout, $sidebar, $template, array $params = array())
+ {
+ $content = $this->template->render($template, $params);
+
+ if ($this->request->isAjax()) {
+ return $content;
+ }
+
+ $params['content_for_sublayout'] = $content;
+ $params['sidebar_template'] = $sidebar;
+
+ return $this->app($sublayout, $params);
+ }
+}
diff --git a/app/Helper/Subtask.php b/app/Helper/Subtask.php
index 1f367b27..1784a2bf 100644
--- a/app/Helper/Subtask.php
+++ b/app/Helper/Subtask.php
@@ -10,32 +10,84 @@ namespace Kanboard\Helper;
*/
class Subtask extends \Kanboard\Core\Base
{
+ public function getTitle(array $subtask)
+ {
+ if ($subtask['status'] == 0) {
+ $html = '<i class="fa fa-square-o fa-fw"></i>';
+ } elseif ($subtask['status'] == 1) {
+ $html = '<i class="fa fa-gears fa-fw"></i>';
+ } else {
+ $html = '<i class="fa fa-check-square-o fa-fw"></i>';
+ }
+
+ return $html.$this->helper->e($subtask['title']);
+ }
+
/**
* Get the link to toggle subtask status
*
* @access public
- * @param array $subtask
- * @param string $redirect
+ * @param array $subtask
+ * @param integer $project_id
+ * @param boolean $refresh_table
* @return string
*/
- public function toggleStatus(array $subtask, $redirect)
+ public function toggleStatus(array $subtask, $project_id, $refresh_table = false)
{
- if ($subtask['status'] == 0 && isset($this->session['has_subtask_inprogress']) && $this->session['has_subtask_inprogress'] === true) {
- return $this->helper->url->link(
- trim($this->template->render('subtask/icons', array('subtask' => $subtask))) . $this->helper->e($subtask['title']),
- 'subtask',
- 'subtaskRestriction',
- array('task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'redirect' => $redirect),
- false,
- 'popover task-board-popover'
- );
+ if (! $this->helper->user->hasProjectAccess('subtask', 'edit', $project_id)) {
+ return $this->getTitle($subtask);
+ }
+
+ $params = array('task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'refresh-table' => (int) $refresh_table);
+
+ if ($subtask['status'] == 0 && isset($this->sessionStorage->hasSubtaskInProgress) && $this->sessionStorage->hasSubtaskInProgress) {
+ return $this->helper->url->link($this->getTitle($subtask), 'SubtaskRestriction', 'popover', $params, false, 'popover');
}
- return $this->helper->url->link(
- trim($this->template->render('subtask/icons', array('subtask' => $subtask))) . $this->helper->e($subtask['title']),
- 'subtask',
- 'toggleStatus',
- array('task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'redirect' => $redirect)
- );
+ $class = 'subtask-toggle-status '.($refresh_table ? 'subtask-refresh-table' : '');
+ return $this->helper->url->link($this->getTitle($subtask), 'SubtaskStatus', 'change', $params, false, $class);
+ }
+
+ public function selectTitle(array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="1"', 'required', 'maxlength="255"'), $attributes);
+
+ $html = $this->helper->form->label(t('Title'), 'title');
+ $html .= $this->helper->form->text('title', $values, $errors, $attributes);
+
+ return $html;
+ }
+
+ public function selectAssignee(array $users, array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="2"'), $attributes);
+
+ $html = $this->helper->form->label(t('Assignee'), 'user_id');
+ $html .= $this->helper->form->select('user_id', $users, $values, $errors, $attributes);
+ $html .= '&nbsp;<a href="#" class="assign-me" data-target-id="form-user_id" data-current-id="'.$this->userSession->getId().'" title="'.t('Assign to me').'">'.t('Me').'</a>';
+
+ return $html;
+ }
+
+ public function selectTimeEstimated(array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="3"'), $attributes);
+
+ $html = $this->helper->form->label(t('Original estimate'), 'time_estimated');
+ $html .= $this->helper->form->numeric('time_estimated', $values, $errors, $attributes);
+ $html .= ' '.t('hours');
+
+ return $html;
+ }
+
+ public function selectTimeSpent(array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="4"'), $attributes);
+
+ $html = $this->helper->form->label(t('Time spent'), 'time_spent');
+ $html .= $this->helper->form->numeric('time_spent', $values, $errors, $attributes);
+ $html .= ' '.t('hours');
+
+ return $html;
}
}
diff --git a/app/Helper/Task.php b/app/Helper/Task.php
index 1405a167..6058c099 100644
--- a/app/Helper/Task.php
+++ b/app/Helper/Task.php
@@ -2,14 +2,24 @@
namespace Kanboard\Helper;
+use Kanboard\Core\Base;
+
/**
* Task helpers
*
* @package helper
* @author Frederic Guillot
*/
-class Task extends \Kanboard\Core\Base
+class Task extends Base
{
+ /**
+ * Local cache for project columns
+ *
+ * @access private
+ * @var array
+ */
+ private $columns = array();
+
public function getColors()
{
return $this->color->getList();
@@ -34,4 +44,143 @@ class Task extends \Kanboard\Core\Base
{
return $this->taskPermission->canRemoveTask($task);
}
+
+ public function selectAssignee(array $users, array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="3"'), $attributes);
+
+ $html = $this->helper->form->label(t('Assignee'), 'owner_id');
+ $html .= $this->helper->form->select('owner_id', $users, $values, $errors, $attributes);
+ $html .= '&nbsp;<a href="#" class="assign-me" data-target-id="form-owner_id" data-current-id="'.$this->userSession->getId().'" title="'.t('Assign to me').'">'.t('Me').'</a>';
+
+ return $html;
+ }
+
+ public function selectCategory(array $categories, array $values, array $errors = array(), array $attributes = array(), $allow_one_item = false)
+ {
+ $attributes = array_merge(array('tabindex="4"'), $attributes);
+ $html = '';
+
+ if (! (! $allow_one_item && count($categories) === 1 && key($categories) == 0)) {
+ $html .= $this->helper->form->label(t('Category'), 'category_id');
+ $html .= $this->helper->form->select('category_id', $categories, $values, $errors, $attributes);
+ }
+
+ return $html;
+ }
+
+ public function selectSwimlane(array $swimlanes, array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="5"'), $attributes);
+ $html = '';
+
+ if (! (count($swimlanes) === 1 && key($swimlanes) == 0)) {
+ $html .= $this->helper->form->label(t('Swimlane'), 'swimlane_id');
+ $html .= $this->helper->form->select('swimlane_id', $swimlanes, $values, $errors, $attributes);
+ }
+
+ return $html;
+ }
+
+ public function selectColumn(array $columns, array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="6"'), $attributes);
+
+ $html = $this->helper->form->label(t('Column'), 'column_id');
+ $html .= $this->helper->form->select('column_id', $columns, $values, $errors, $attributes);
+
+ return $html;
+ }
+
+ public function selectPriority(array $project, array $values)
+ {
+ $html = '';
+
+ if ($project['priority_end'] > $project['priority_start']) {
+ $range = range($project['priority_start'], $project['priority_end']);
+ $options = array_combine($range, $range);
+ $values += array('priority' => $project['priority_default']);
+
+ $html .= $this->helper->form->label(t('Priority'), 'priority');
+ $html .= $this->helper->form->select('priority', $options, $values, array(), array('tabindex="7"'));
+ }
+
+ return $html;
+ }
+
+ public function selectScore(array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="8"'), $attributes);
+
+ $html = $this->helper->form->label(t('Complexity'), 'score');
+ $html .= $this->helper->form->number('score', $values, $errors, $attributes);
+
+ return $html;
+ }
+
+ public function selectTimeEstimated(array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="9"'), $attributes);
+
+ $html = $this->helper->form->label(t('Original estimate'), 'time_estimated');
+ $html .= $this->helper->form->numeric('time_estimated', $values, $errors, $attributes);
+ $html .= ' '.t('hours');
+
+ return $html;
+ }
+
+ public function selectTimeSpent(array $values, array $errors = array(), array $attributes = array())
+ {
+ $attributes = array_merge(array('tabindex="10"'), $attributes);
+
+ $html = $this->helper->form->label(t('Time spent'), 'time_spent');
+ $html .= $this->helper->form->numeric('time_spent', $values, $errors, $attributes);
+ $html .= ' '.t('hours');
+
+ return $html;
+ }
+
+ public function selectStartDate(array $values, array $errors = array(), array $attributes = array())
+ {
+ $placeholder = date($this->config->get('application_date_format', 'm/d/Y H:i'));
+ $attributes = array_merge(array('tabindex="11"', 'placeholder="'.$placeholder.'"'), $attributes);
+
+ $html = $this->helper->form->label(t('Start Date'), 'date_started');
+ $html .= $this->helper->form->text('date_started', $values, $errors, $attributes, 'form-datetime');
+
+ return $html;
+ }
+
+ public function selectDueDate(array $values, array $errors = array(), array $attributes = array())
+ {
+ $placeholder = date($this->config->get('application_date_format', 'm/d/Y'));
+ $attributes = array_merge(array('tabindex="12"', 'placeholder="'.$placeholder.'"'), $attributes);
+
+ $html = $this->helper->form->label(t('Due Date'), 'date_due');
+ $html .= $this->helper->form->text('date_due', $values, $errors, $attributes, 'form-date');
+
+ return $html;
+ }
+
+ public function formatPriority(array $project, array $task)
+ {
+ $html = '';
+
+ if ($project['priority_end'] > $project['priority_start']) {
+ $html .= '<span class="task-board-priority" title="'.t('Task priority').'">';
+ $html .= $task['priority'] >= 0 ? 'P'.$task['priority'] : '-P'.abs($task['priority']);
+ $html .= '</span>';
+ }
+
+ return $html;
+ }
+
+ public function getProgress($task)
+ {
+ if (! isset($this->columns[$task['project_id']])) {
+ $this->columns[$task['project_id']] = $this->column->getList($task['project_id']);
+ }
+
+ return $this->task->getProgress($task, $this->columns[$task['project_id']]);
+ }
}
diff --git a/app/Helper/Text.php b/app/Helper/Text.php
index d2075fe4..83f1e3f9 100644
--- a/app/Helper/Text.php
+++ b/app/Helper/Text.php
@@ -3,14 +3,15 @@
namespace Kanboard\Helper;
use Kanboard\Core\Markdown;
+use Kanboard\Core\Base;
/**
- * Text helpers
+ * Text Helpers
*
* @package helper
* @author Frederic Guillot
*/
-class Text extends \Kanboard\Core\Base
+class Text extends Base
{
/**
* Markdown transformation
@@ -21,7 +22,7 @@ class Text extends \Kanboard\Core\Base
*/
public function markdown($text, array $link = array())
{
- $parser = new Markdown($link, $this->helper->url);
+ $parser = new Markdown($this->container, $link);
$parser->setMarkupEscaped(MARKDOWN_ESCAPE_HTML);
return $parser->text($text);
}
@@ -42,6 +43,29 @@ class Text extends \Kanboard\Core\Base
}
/**
+ * Get the number of bytes from PHP size
+ *
+ * @param integer $val PHP size (example: 2M)
+ * @return integer
+ */
+ public function phpToBytes($val)
+ {
+ $val = trim($val);
+ $last = strtolower($val[strlen($val)-1]);
+
+ switch ($last) {
+ case 'g':
+ $val *= 1024;
+ case 'm':
+ $val *= 1024;
+ case 'k':
+ $val *= 1024;
+ }
+
+ return $val;
+ }
+
+ /**
* Return true if needle is contained in the haystack
*
* @param string $haystack Haystack
diff --git a/app/Helper/Url.php b/app/Helper/Url.php
index f120252d..7de8a571 100644
--- a/app/Helper/Url.php
+++ b/app/Helper/Url.php
@@ -2,8 +2,7 @@
namespace Kanboard\Helper;
-use Kanboard\Core\Request;
-use Kanboard\Core\Security;
+use Kanboard\Core\Base;
/**
* Url helpers
@@ -11,7 +10,7 @@ use Kanboard\Core\Security;
* @package helper
* @author Frederic Guillot
*/
-class Url extends \Kanboard\Core\Base
+class Url extends Base
{
private $base = '';
private $directory = '';
@@ -45,7 +44,7 @@ class Url extends \Kanboard\Core\Base
*/
public function link($label, $controller, $action, array $params = array(), $csrf = false, $class = '', $title = '', $new_tab = false, $anchor = '')
{
- return '<a href="'.$this->href($controller, $action, $params, $csrf, $anchor).'" class="'.$class.'" title="'.$title.'" '.($new_tab ? 'target="_blank"' : '').'>'.$label.'</a>';
+ return '<a href="'.$this->href($controller, $action, $params, $csrf, $anchor).'" class="'.$class.'" title=\''.$title.'\' '.($new_tab ? 'target="_blank"' : '').'>'.$label.'</a>';
}
/**
@@ -104,8 +103,8 @@ class Url extends \Kanboard\Core\Base
*/
public function dir()
{
- if (empty($this->directory) && isset($_SERVER['REQUEST_METHOD'])) {
- $this->directory = str_replace('\\', '/', dirname($_SERVER['PHP_SELF']));
+ if ($this->directory === '' && $this->request->getMethod() !== '') {
+ $this->directory = str_replace('\\', '/', dirname($this->request->getServerVariable('PHP_SELF')));
$this->directory = $this->directory !== '/' ? $this->directory.'/' : '/';
$this->directory = str_replace('//', '/', $this->directory);
}
@@ -121,13 +120,13 @@ class Url extends \Kanboard\Core\Base
*/
public function server()
{
- if (empty($_SERVER['SERVER_NAME'])) {
+ if ($this->request->getServerVariable('SERVER_NAME') === '') {
return 'http://localhost/';
}
- $url = Request::isHTTPS() ? 'https://' : 'http://';
- $url .= $_SERVER['SERVER_NAME'];
- $url .= $_SERVER['SERVER_PORT'] == 80 || $_SERVER['SERVER_PORT'] == 443 ? '' : ':'.$_SERVER['SERVER_PORT'];
+ $url = $this->request->isHTTPS() ? 'https://' : 'http://';
+ $url .= $this->request->getServerVariable('SERVER_NAME');
+ $url .= $this->request->getServerVariable('SERVER_PORT') == 80 || $this->request->getServerVariable('SERVER_PORT') == 443 ? '' : ':'.$this->request->getServerVariable('SERVER_PORT');
$url .= $this->dir() ?: '/';
return $url;
@@ -148,17 +147,19 @@ class Url extends \Kanboard\Core\Base
*/
private function build($separator, $controller, $action, array $params = array(), $csrf = false, $anchor = '', $absolute = false)
{
- $path = $this->router->findUrl($controller, $action, $params);
+ $path = $this->route->findUrl($controller, $action, $params);
$qs = array();
if (empty($path)) {
$qs['controller'] = $controller;
$qs['action'] = $action;
$qs += $params;
+ } else {
+ unset($params['plugin']);
}
if ($csrf) {
- $qs['csrf_token'] = Security::getCSRFToken();
+ $qs['csrf_token'] = $this->token->getCSRFToken();
}
if (! empty($qs)) {
diff --git a/app/Helper/User.php b/app/Helper/User.php
index 9cd39bd9..29844dfb 100644
--- a/app/Helper/User.php
+++ b/app/Helper/User.php
@@ -51,21 +51,6 @@ class User extends \Kanboard\Core\Base
}
/**
- * Get user profile
- *
- * @access public
- * @return string
- */
- public function getProfileLink()
- {
- return $this->helper->url->link(
- $this->helper->e($this->getFullname()),
- 'user',
- 'show',
- array('user_id' => $this->userSession->getId())
- );
- }
- /**
* Check if the given user_id is the connected user
*
* @param integer $user_id User id
@@ -88,44 +73,77 @@ class User extends \Kanboard\Core\Base
}
/**
- * Return if the logged user is project admin
+ * Get role name
*
* @access public
- * @return boolean
+ * @param string $role
+ * @return string
*/
- public function isProjectAdmin()
+ public function getRoleName($role = '')
{
- return $this->userSession->isProjectAdmin();
+ return $this->role->getRoleName($role ?: $this->userSession->getRole());
}
/**
- * Check for project administration actions access (Project Admin group)
+ * Check application access
*
- * @access public
- * @return boolean
+ * @param string $controller
+ * @param string $action
+ * @return bool
*/
- public function isProjectAdministrationAllowed($project_id)
+ public function hasAccess($controller, $action)
{
- if ($this->userSession->isAdmin()) {
- return true;
+ $key = 'app_access:'.$controller.$action;
+ $result = $this->memoryCache->get($key);
+
+ if ($result === null) {
+ $result = $this->applicationAuthorization->isAllowed($controller, $action, $this->userSession->getRole());
+ $this->memoryCache->set($key, $result);
}
- return $this->memoryCache->proxy($this->container['acl'], 'handleProjectAdminPermissions', $project_id);
+ return $result;
}
/**
- * Check for project management actions access (Regular users who are Project Managers)
+ * Check project access
*
- * @access public
- * @return boolean
+ * @param string $controller
+ * @param string $action
+ * @param integer $project_id
+ * @return bool
*/
- public function isProjectManagementAllowed($project_id)
+ public function hasProjectAccess($controller, $action, $project_id)
{
if ($this->userSession->isAdmin()) {
return true;
}
- return $this->memoryCache->proxy($this->container['acl'], 'handleProjectManagerPermissions', $project_id);
+ if (! $this->hasAccess($controller, $action)) {
+ return false;
+ }
+
+ $key = 'project_access:'.$controller.$action.$project_id;
+ $result = $this->memoryCache->get($key);
+
+ if ($result === null) {
+ $role = $this->getProjectUserRole($project_id);
+ $result = $this->projectAuthorization->isAllowed($controller, $action, $role);
+ $this->memoryCache->set($key, $result);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get project role for the current user
+ *
+ * @access public
+ * @param integer $project_id
+ * @return string
+ */
+ public function getProjectUserRole($project_id)
+ {
+ return $this->memoryCache->proxy($this->projectUserRole, 'getUserRole', $project_id, $this->userSession->getId());
}
/**
@@ -136,7 +154,7 @@ class User extends \Kanboard\Core\Base
*/
public function getFullname(array $user = array())
{
- return $this->user->getFullname(empty($user) ? $_SESSION['user'] : $user);
+ return $this->user->getFullname(empty($user) ? $this->sessionStorage->user : $user);
}
/**
diff --git a/app/Integration/BitbucketWebhook.php b/app/Integration/BitbucketWebhook.php
deleted file mode 100644
index 97a39437..00000000
--- a/app/Integration/BitbucketWebhook.php
+++ /dev/null
@@ -1,312 +0,0 @@
-<?php
-
-namespace Kanboard\Integration;
-
-use Kanboard\Event\GenericEvent;
-
-/**
- * Bitbucket Webhook
- *
- * @package integration
- * @author Frederic Guillot
- */
-class BitbucketWebhook extends \Kanboard\Core\Base
-{
- /**
- * Events
- *
- * @var string
- */
- const EVENT_COMMIT = 'bitbucket.webhook.commit';
- const EVENT_ISSUE_OPENED = 'bitbucket.webhook.issue.opened';
- const EVENT_ISSUE_CLOSED = 'bitbucket.webhook.issue.closed';
- const EVENT_ISSUE_REOPENED = 'bitbucket.webhook.issue.reopened';
- const EVENT_ISSUE_ASSIGNEE_CHANGE = 'bitbucket.webhook.issue.assignee';
- const EVENT_ISSUE_COMMENT = 'bitbucket.webhook.issue.commented';
-
- /**
- * Project id
- *
- * @access private
- * @var integer
- */
- private $project_id = 0;
-
- /**
- * Set the project id
- *
- * @access public
- * @param integer $project_id Project id
- */
- public function setProjectId($project_id)
- {
- $this->project_id = $project_id;
- }
-
- /**
- * Parse incoming events
- *
- * @access public
- * @param string $type Bitbucket event type
- * @param array $payload Bitbucket event
- * @return boolean
- */
- public function parsePayload($type, array $payload)
- {
- switch ($type) {
- case 'issue:comment_created':
- return $this->handleCommentCreated($payload);
- case 'issue:created':
- return $this->handleIssueOpened($payload);
- case 'issue:updated':
- return $this->handleIssueUpdated($payload);
- case 'repo:push':
- return $this->handlePush($payload);
- }
-
- return false;
- }
-
- /**
- * Parse comment issue events
- *
- * @access public
- * @param array $payload
- * @return boolean
- */
- public function handleCommentCreated(array $payload)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $payload['issue']['id']);
-
- if (! empty($task)) {
- $user = $this->user->getByUsername($payload['actor']['username']);
-
- if (! empty($user) && ! $this->projectPermission->isMember($this->project_id, $user['id'])) {
- $user = array();
- }
-
- $event = array(
- 'project_id' => $this->project_id,
- 'reference' => $payload['comment']['id'],
- 'comment' => $payload['comment']['content']['raw']."\n\n[".t('By @%s on Bitbucket', $payload['actor']['display_name']).']('.$payload['comment']['links']['html']['href'].')',
- 'user_id' => ! empty($user) ? $user['id'] : 0,
- 'task_id' => $task['id'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_COMMENT,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle new issues
- *
- * @access public
- * @param array $payload
- * @return boolean
- */
- public function handleIssueOpened(array $payload)
- {
- $event = array(
- 'project_id' => $this->project_id,
- 'reference' => $payload['issue']['id'],
- 'title' => $payload['issue']['title'],
- 'description' => $payload['issue']['content']['raw']."\n\n[".t('Bitbucket Issue').']('.$payload['issue']['links']['html']['href'].')',
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_OPENED,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- /**
- * Handle issue updates
- *
- * @access public
- * @param array $payload
- * @return boolean
- */
- public function handleIssueUpdated(array $payload)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $payload['issue']['id']);
-
- if (empty($task)) {
- return false;
- }
-
- if (isset($payload['changes']['status'])) {
- return $this->handleStatusChange($task, $payload);
- } elseif (isset($payload['changes']['assignee'])) {
- return $this->handleAssigneeChange($task, $payload);
- }
-
- return false;
- }
-
- /**
- * Handle issue status change
- *
- * @access public
- * @param array $task
- * @param array $payload
- * @return boolean
- */
- public function handleStatusChange(array $task, array $payload)
- {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'reference' => $payload['issue']['id'],
- );
-
- switch ($payload['issue']['state']) {
- case 'closed':
- $this->container['dispatcher']->dispatch(self::EVENT_ISSUE_CLOSED, new GenericEvent($event));
- return true;
- case 'open':
- $this->container['dispatcher']->dispatch(self::EVENT_ISSUE_REOPENED, new GenericEvent($event));
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle issue assignee change
- *
- * @access public
- * @param array $task
- * @param array $payload
- * @return boolean
- */
- public function handleAssigneeChange(array $task, array $payload)
- {
- if (empty($payload['issue']['assignee'])) {
- return $this->handleIssueUnassigned($task, $payload);
- }
-
- return $this->handleIssueAssigned($task, $payload);
- }
-
- /**
- * Handle issue assigned
- *
- * @access public
- * @param array $task
- * @param array $payload
- * @return boolean
- */
- public function handleIssueAssigned(array $task, array $payload)
- {
- $user = $this->user->getByUsername($payload['issue']['assignee']['username']);
-
- if (empty($user)) {
- return false;
- }
-
- if (! $this->projectPermission->isMember($this->project_id, $user['id'])) {
- return false;
- }
-
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'owner_id' => $user['id'],
- 'reference' => $payload['issue']['id'],
- );
-
- $this->container['dispatcher']->dispatch(self::EVENT_ISSUE_ASSIGNEE_CHANGE, new GenericEvent($event));
-
- return true;
- }
-
- /**
- * Handle issue unassigned
- *
- * @access public
- * @param array $task
- * @param array $payload
- * @return boolean
- */
- public function handleIssueUnassigned(array $task, array $payload)
- {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'owner_id' => 0,
- 'reference' => $payload['issue']['id'],
- );
-
- $this->container['dispatcher']->dispatch(self::EVENT_ISSUE_ASSIGNEE_CHANGE, new GenericEvent($event));
-
- return true;
- }
-
- /**
- * Parse push events
- *
- * @access public
- * @param array $payload
- * @return boolean
- */
- public function handlePush(array $payload)
- {
- if (isset($payload['push']['changes'])) {
- foreach ($payload['push']['changes'] as $change) {
- if (isset($change['new']['target']) && $this->handleCommit($change['new']['target'], $payload['actor'])) {
- return true;
- }
- }
- }
-
- return false;
- }
-
- /**
- * Parse commit
- *
- * @access public
- * @param array $commit Bitbucket commit
- * @param array $actor Bitbucket actor
- * @return boolean
- */
- public function handleCommit(array $commit, array $actor)
- {
- $task_id = $this->task->getTaskIdFromText($commit['message']);
-
- if (empty($task_id)) {
- return false;
- }
-
- $task = $this->taskFinder->getById($task_id);
-
- if (empty($task)) {
- return false;
- }
-
- if ($task['project_id'] != $this->project_id) {
- return false;
- }
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_COMMIT,
- new GenericEvent(array(
- 'task_id' => $task_id,
- 'commit_message' => $commit['message'],
- 'commit_url' => $commit['links']['html']['href'],
- 'commit_comment' => $commit['message']."\n\n[".t('Commit made by @%s on Bitbucket', $actor['display_name']).']('.$commit['links']['html']['href'].')',
- ) + $task)
- );
-
- return true;
- }
-}
diff --git a/app/Integration/GithubWebhook.php b/app/Integration/GithubWebhook.php
deleted file mode 100644
index c8b53e37..00000000
--- a/app/Integration/GithubWebhook.php
+++ /dev/null
@@ -1,380 +0,0 @@
-<?php
-
-namespace Kanboard\Integration;
-
-use Kanboard\Event\GenericEvent;
-
-/**
- * Github Webhook
- *
- * @package integration
- * @author Frederic Guillot
- */
-class GithubWebhook extends \Kanboard\Core\Base
-{
- /**
- * Events
- *
- * @var string
- */
- const EVENT_ISSUE_OPENED = 'github.webhook.issue.opened';
- const EVENT_ISSUE_CLOSED = 'github.webhook.issue.closed';
- const EVENT_ISSUE_REOPENED = 'github.webhook.issue.reopened';
- const EVENT_ISSUE_ASSIGNEE_CHANGE = 'github.webhook.issue.assignee';
- const EVENT_ISSUE_LABEL_CHANGE = 'github.webhook.issue.label';
- const EVENT_ISSUE_COMMENT = 'github.webhook.issue.commented';
- const EVENT_COMMIT = 'github.webhook.commit';
-
- /**
- * Project id
- *
- * @access private
- * @var integer
- */
- private $project_id = 0;
-
- /**
- * Set the project id
- *
- * @access public
- * @param integer $project_id Project id
- */
- public function setProjectId($project_id)
- {
- $this->project_id = $project_id;
- }
-
- /**
- * Parse Github events
- *
- * @access public
- * @param string $type Github event type
- * @param array $payload Github event
- * @return boolean
- */
- public function parsePayload($type, array $payload)
- {
- switch ($type) {
- case 'push':
- return $this->parsePushEvent($payload);
- case 'issues':
- return $this->parseIssueEvent($payload);
- case 'issue_comment':
- return $this->parseCommentIssueEvent($payload);
- }
-
- return false;
- }
-
- /**
- * Parse Push events (list of commits)
- *
- * @access public
- * @param array $payload Event data
- * @return boolean
- */
- public function parsePushEvent(array $payload)
- {
- foreach ($payload['commits'] as $commit) {
- $task_id = $this->task->getTaskIdFromText($commit['message']);
-
- if (empty($task_id)) {
- continue;
- }
-
- $task = $this->taskFinder->getById($task_id);
-
- if (empty($task)) {
- continue;
- }
-
- if ($task['project_id'] != $this->project_id) {
- continue;
- }
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_COMMIT,
- new GenericEvent(array(
- 'task_id' => $task_id,
- 'commit_message' => $commit['message'],
- 'commit_url' => $commit['url'],
- 'commit_comment' => $commit['message']."\n\n[".t('Commit made by @%s on Github', $commit['author']['username']).']('.$commit['url'].')'
- ) + $task)
- );
- }
-
- return true;
- }
-
- /**
- * Parse issue events
- *
- * @access public
- * @param array $payload Event data
- * @return boolean
- */
- public function parseIssueEvent(array $payload)
- {
- switch ($payload['action']) {
- case 'opened':
- return $this->handleIssueOpened($payload['issue']);
- case 'closed':
- return $this->handleIssueClosed($payload['issue']);
- case 'reopened':
- return $this->handleIssueReopened($payload['issue']);
- case 'assigned':
- return $this->handleIssueAssigned($payload['issue']);
- case 'unassigned':
- return $this->handleIssueUnassigned($payload['issue']);
- case 'labeled':
- return $this->handleIssueLabeled($payload['issue'], $payload['label']);
- case 'unlabeled':
- return $this->handleIssueUnlabeled($payload['issue'], $payload['label']);
- }
-
- return false;
- }
-
- /**
- * Parse comment issue events
- *
- * @access public
- * @param array $payload Event data
- * @return boolean
- */
- public function parseCommentIssueEvent(array $payload)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $payload['issue']['number']);
-
- if (! empty($task)) {
- $user = $this->user->getByUsername($payload['comment']['user']['login']);
-
- if (! empty($user) && ! $this->projectPermission->isMember($this->project_id, $user['id'])) {
- $user = array();
- }
-
- $event = array(
- 'project_id' => $this->project_id,
- 'reference' => $payload['comment']['id'],
- 'comment' => $payload['comment']['body']."\n\n[".t('By @%s on Github', $payload['comment']['user']['login']).']('.$payload['comment']['html_url'].')',
- 'user_id' => ! empty($user) ? $user['id'] : 0,
- 'task_id' => $task['id'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_COMMENT,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle new issues
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueOpened(array $issue)
- {
- $event = array(
- 'project_id' => $this->project_id,
- 'reference' => $issue['number'],
- 'title' => $issue['title'],
- 'description' => $issue['body']."\n\n[".t('Github Issue').']('.$issue['html_url'].')',
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_OPENED,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- /**
- * Handle issue closing
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueClosed(array $issue)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $issue['number']);
-
- if (! empty($task)) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'reference' => $issue['number'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_CLOSED,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle issue reopened
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueReopened(array $issue)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $issue['number']);
-
- if (! empty($task)) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'reference' => $issue['number'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_REOPENED,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle issue assignee change
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueAssigned(array $issue)
- {
- $user = $this->user->getByUsername($issue['assignee']['login']);
- $task = $this->taskFinder->getByReference($this->project_id, $issue['number']);
-
- if (! empty($user) && ! empty($task) && $this->projectPermission->isMember($this->project_id, $user['id'])) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'owner_id' => $user['id'],
- 'reference' => $issue['number'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_ASSIGNEE_CHANGE,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle unassigned issue
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueUnassigned(array $issue)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $issue['number']);
-
- if (! empty($task)) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'owner_id' => 0,
- 'reference' => $issue['number'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_ASSIGNEE_CHANGE,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle labeled issue
- *
- * @access public
- * @param array $issue Issue data
- * @param array $label Label data
- * @return boolean
- */
- public function handleIssueLabeled(array $issue, array $label)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $issue['number']);
-
- if (! empty($task)) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'reference' => $issue['number'],
- 'label' => $label['name'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_LABEL_CHANGE,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Handle unlabeled issue
- *
- * @access public
- * @param array $issue Issue data
- * @param array $label Label data
- * @return boolean
- */
- public function handleIssueUnlabeled(array $issue, array $label)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $issue['number']);
-
- if (! empty($task)) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'reference' => $issue['number'],
- 'label' => $label['name'],
- 'category_id' => 0,
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_LABEL_CHANGE,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-}
diff --git a/app/Integration/GitlabWebhook.php b/app/Integration/GitlabWebhook.php
deleted file mode 100644
index b3f9b0b5..00000000
--- a/app/Integration/GitlabWebhook.php
+++ /dev/null
@@ -1,265 +0,0 @@
-<?php
-
-namespace Kanboard\Integration;
-
-use Kanboard\Event\GenericEvent;
-
-/**
- * Gitlab Webhook
- *
- * @package integration
- * @author Frederic Guillot
- */
-class GitlabWebhook extends \Kanboard\Core\Base
-{
- /**
- * Events
- *
- * @var string
- */
- const EVENT_ISSUE_OPENED = 'gitlab.webhook.issue.opened';
- const EVENT_ISSUE_CLOSED = 'gitlab.webhook.issue.closed';
- const EVENT_COMMIT = 'gitlab.webhook.commit';
- const EVENT_ISSUE_COMMENT = 'gitlab.webhook.issue.commented';
-
- /**
- * Supported webhook events
- *
- * @var string
- */
- const TYPE_PUSH = 'push';
- const TYPE_ISSUE = 'issue';
- const TYPE_COMMENT = 'comment';
-
- /**
- * Project id
- *
- * @access private
- * @var integer
- */
- private $project_id = 0;
-
- /**
- * Set the project id
- *
- * @access public
- * @param integer $project_id Project id
- */
- public function setProjectId($project_id)
- {
- $this->project_id = $project_id;
- }
-
- /**
- * Parse events
- *
- * @access public
- * @param array $payload Gitlab event
- * @return boolean
- */
- public function parsePayload(array $payload)
- {
- switch ($this->getType($payload)) {
- case self::TYPE_PUSH:
- return $this->handlePushEvent($payload);
- case self::TYPE_ISSUE;
- return $this->handleIssueEvent($payload);
- case self::TYPE_COMMENT;
- return $this->handleCommentEvent($payload);
- }
-
- return false;
- }
-
- /**
- * Get event type
- *
- * @access public
- * @param array $payload Gitlab event
- * @return string
- */
- public function getType(array $payload)
- {
- if (empty($payload['object_kind'])) {
- return '';
- }
-
- switch ($payload['object_kind']) {
- case 'issue':
- return self::TYPE_ISSUE;
- case 'note':
- return self::TYPE_COMMENT;
- case 'push':
- return self::TYPE_PUSH;
- default:
- return '';
- }
- }
-
- /**
- * Parse push event
- *
- * @access public
- * @param array $payload Gitlab event
- * @return boolean
- */
- public function handlePushEvent(array $payload)
- {
- foreach ($payload['commits'] as $commit) {
- $this->handleCommit($commit);
- }
-
- return true;
- }
-
- /**
- * Parse commit
- *
- * @access public
- * @param array $commit Gitlab commit
- * @return boolean
- */
- public function handleCommit(array $commit)
- {
- $task_id = $this->task->getTaskIdFromText($commit['message']);
-
- if (empty($task_id)) {
- return false;
- }
-
- $task = $this->taskFinder->getById($task_id);
-
- if (empty($task)) {
- return false;
- }
-
- if ($task['project_id'] != $this->project_id) {
- return false;
- }
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_COMMIT,
- new GenericEvent(array(
- 'task_id' => $task_id,
- 'commit_message' => $commit['message'],
- 'commit_url' => $commit['url'],
- 'commit_comment' => $commit['message']."\n\n[".t('Commit made by @%s on Gitlab', $commit['author']['name']).']('.$commit['url'].')'
- ) + $task)
- );
-
- return true;
- }
-
- /**
- * Parse issue event
- *
- * @access public
- * @param array $payload Gitlab event
- * @return boolean
- */
- public function handleIssueEvent(array $payload)
- {
- switch ($payload['object_attributes']['action']) {
- case 'open':
- return $this->handleIssueOpened($payload['object_attributes']);
- case 'close':
- return $this->handleIssueClosed($payload['object_attributes']);
- }
-
- return false;
- }
-
- /**
- * Handle new issues
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueOpened(array $issue)
- {
- $event = array(
- 'project_id' => $this->project_id,
- 'reference' => $issue['id'],
- 'title' => $issue['title'],
- 'description' => $issue['description']."\n\n[".t('Gitlab Issue').']('.$issue['url'].')',
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_OPENED,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- /**
- * Handle issue closing
- *
- * @access public
- * @param array $issue Issue data
- * @return boolean
- */
- public function handleIssueClosed(array $issue)
- {
- $task = $this->taskFinder->getByReference($this->project_id, $issue['id']);
-
- if (! empty($task)) {
- $event = array(
- 'project_id' => $this->project_id,
- 'task_id' => $task['id'],
- 'reference' => $issue['id'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_CLOSED,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Parse comment issue events
- *
- * @access public
- * @param array $payload Event data
- * @return boolean
- */
- public function handleCommentEvent(array $payload)
- {
- if (! isset($payload['issue'])) {
- return false;
- }
-
- $task = $this->taskFinder->getByReference($this->project_id, $payload['issue']['id']);
-
- if (! empty($task)) {
- $user = $this->user->getByUsername($payload['user']['username']);
-
- if (! empty($user) && ! $this->projectPermission->isMember($this->project_id, $user['id'])) {
- $user = array();
- }
-
- $event = array(
- 'project_id' => $this->project_id,
- 'reference' => $payload['object_attributes']['id'],
- 'comment' => $payload['object_attributes']['note']."\n\n[".t('By @%s on Gitlab', $payload['user']['username']).']('.$payload['object_attributes']['url'].')',
- 'user_id' => ! empty($user) ? $user['id'] : 0,
- 'task_id' => $task['id'],
- );
-
- $this->container['dispatcher']->dispatch(
- self::EVENT_ISSUE_COMMENT,
- new GenericEvent($event)
- );
-
- return true;
- }
-
- return false;
- }
-}
diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php
new file mode 100644
index 00000000..3abb095f
--- /dev/null
+++ b/app/Locale/bs_BA/translations.php
@@ -0,0 +1,1151 @@
+<?php
+
+return array(
+ 'number.decimals_separator' => ',',
+ 'number.thousands_separator' => '.',
+ 'None' => 'None',
+ 'edit' => 'uredi',
+ 'Edit' => 'Uredi',
+ 'remove' => 'ukloni',
+ 'Remove' => 'Ukloni',
+ 'Update' => 'Ažuriraj',
+ 'Yes' => 'Da',
+ 'No' => 'Ne',
+ 'cancel' => 'odustani',
+ 'or' => 'ili',
+ 'Yellow' => 'Žuta',
+ 'Blue' => 'Plava',
+ 'Green' => 'Zelena',
+ 'Purple' => 'Ljubičasta',
+ 'Red' => 'Crvena',
+ 'Orange' => 'Narandžasta',
+ 'Grey' => 'Siva',
+ 'Brown' => 'Smeđa',
+ 'Deep Orange' => 'Tamno narandžasta',
+ 'Dark Grey' => 'Tamno siva',
+ 'Pink' => 'Roze',
+ 'Teal' => 'Tirkizna',
+ 'Cyan' => 'Zelenkasto plava',
+ 'Lime' => 'Žućkasto zelena',
+ 'Light Green' => 'Svijetlo zelena',
+ 'Amber' => 'Ćilibarska',
+ 'Save' => 'Sačuvaj',
+ 'Login' => 'Prijava',
+ 'Official website:' => 'Zvanična stranica:',
+ 'Unassigned' => 'Nedodijeljen',
+ 'View this task' => 'Pregledaj zadatak',
+ 'Remove user' => 'Ukloni korisnika',
+ 'Do you really want to remove this user: "%s"?' => 'Da li zaista želiš da ukloniš korisnika: "%s"?',
+ 'New user' => 'Novi korisnik',
+ 'All users' => 'Svi korisnici',
+ 'Username' => 'Korisničko ime',
+ 'Password' => 'Šifra',
+ 'Administrator' => 'Administrator',
+ 'Sign in' => 'Prijava',
+ 'Users' => 'Korisnici',
+ 'No user' => 'Nema korisnika',
+ 'Forbidden' => 'Zabranjeno',
+ 'Access Forbidden' => 'Pristup zabranjen',
+ 'Edit user' => 'Uredi korisnika',
+ 'Logout' => 'Odjava',
+ 'Bad username or password' => 'Pogrešno korisničko ime ili šifra',
+ 'Edit project' => 'Uredi projekat',
+ 'Name' => 'Ime',
+ 'Projects' => 'Projekti',
+ 'No project' => 'Bez projekta',
+ 'Project' => 'Projekat',
+ 'Status' => 'Status',
+ 'Tasks' => 'Zadatak',
+ 'Board' => 'Tabla',
+ 'Actions' => 'Akcije',
+ 'Inactive' => 'Neaktivan',
+ 'Active' => 'Aktivan',
+ 'Add this column' => 'Dodaj kolonu',
+ '%d tasks on the board' => '%d zadataka na tabli',
+ '%d tasks in total' => '%d zadataka ukupno',
+ 'Unable to update this board.' => 'Nemogu da ažuriram ovu tablu.',
+ 'Edit board' => 'Izmijeni tablu',
+ 'Disable' => 'Onemogući',
+ 'Enable' => 'Omogući',
+ 'New project' => 'Novi projekat',
+ 'Do you really want to remove this project: "%s"?' => 'Da li želiš da ukloniš projekat: "%s"?',
+ 'Remove project' => 'Ukloni projekat',
+ 'Edit the board for "%s"' => 'Uredi tablu za "%s"',
+ 'All projects' => 'Svi projekti',
+ 'Add a new column' => 'Dodaj novu kolonu',
+ 'Title' => 'Naslov',
+ 'Nobody assigned' => 'Niko nije dodijeljen',
+ 'Assigned to %s' => 'Dodijeljen korisniku %s',
+ 'Remove a column' => 'Ukloni kolonu',
+ 'Remove a column from a board' => 'Ukloni kolonu sa table',
+ 'Unable to remove this column.' => 'Nemoguće uklanjanje kolone.',
+ 'Do you really want to remove this column: "%s"?' => 'Da li zaista želiš da ukoniš ovu kolonu: "%s"?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'Ova akcija BRIŠE SVE ZADATKE vezane za ovu kolonu!',
+ 'Settings' => 'Podešavanja',
+ 'Application settings' => 'Podešavanja aplikacije',
+ 'Language' => 'Jezik',
+ 'Webhook token:' => 'Token:',
+ 'API token:' => 'Token za API',
+ 'Database size:' => 'Veličina baze:',
+ 'Download the database' => 'Preuzmi bazu',
+ 'Optimize the database' => 'Optimizuj bazu',
+ '(VACUUM command)' => '(Naredba VACUUM)',
+ '(Gzip compressed Sqlite file)' => '(Sqlite baza spakovana Gzip-om)',
+ 'Close a task' => 'Zatvori zadatak',
+ 'Edit a task' => 'Uredi zadatak',
+ 'Column' => 'Kolona',
+ 'Color' => 'Boja',
+ 'Assignee' => 'Izvršilac',
+ 'Create another task' => 'Dodaj zadatak',
+ 'New task' => 'Novi zadatak',
+ 'Open a task' => 'Otvori zadatak',
+ 'Do you really want to open this task: "%s"?' => 'Da li zaista želiš da otvoriš zadatak: "%s"?',
+ 'Back to the board' => 'Nazad na tablu',
+ 'There is nobody assigned' => 'Niko nije dodijeljen!',
+ 'Column on the board:' => 'Kolona na tabli:',
+ 'Close this task' => 'Zatvori ovaj zadatak',
+ 'Open this task' => 'Otvori ovaj zadatak',
+ 'There is no description.' => 'Bez opisa.',
+ 'Add a new task' => 'Dodaj zadatak',
+ 'The username is required' => 'Korisničko ime je obavezno',
+ 'The maximum length is %d characters' => 'Maksimalna dužina je %d znakova',
+ 'The minimum length is %d characters' => 'Minimalna dužina je %d znakova',
+ 'The password is required' => 'Šifra je obavezna',
+ 'This value must be an integer' => 'Mora biti cio broj',
+ 'The username must be unique' => 'Korisničko ime mora biti jedinstveno',
+ 'The user id is required' => 'ID korisnika je obavezan',
+ 'Passwords don\'t match' => 'Šifre se ne podudaraju',
+ 'The confirmation is required' => 'Potvrda je obavezna',
+ 'The project is required' => 'Projekat je obavezan',
+ 'The id is required' => 'ID je obavezan',
+ 'The project id is required' => 'ID projekta je obavezan',
+ 'The project name is required' => 'Naziv projekta je obavezan',
+ 'The title is required' => 'Naslov je obavezan',
+ 'Settings saved successfully.' => 'Podešavanja uspješno sačuvana.',
+ 'Unable to save your settings.' => 'Nemoguće sačuvati podešavanja.',
+ 'Database optimization done.' => 'Optimizacija baze je završena.',
+ 'Your project have been created successfully.' => 'Projekat je uspješno napravljen.',
+ 'Unable to create your project.' => 'Nemoguće kreiranje projekta.',
+ 'Project updated successfully.' => 'Projekat je uspješno ažuriran.',
+ 'Unable to update this project.' => 'Nemoguće ažuriranje projekta.',
+ 'Unable to remove this project.' => 'Nemoguće uklanjanje projekta.',
+ 'Project removed successfully.' => 'Projekat uspješno uklonjen.',
+ 'Project activated successfully.' => 'Projekt uspješno aktiviran.',
+ 'Unable to activate this project.' => 'Nemoguće aktiviranje projekta.',
+ 'Project disabled successfully.' => 'Projekat uspješno deaktiviran.',
+ 'Unable to disable this project.' => 'nemoguće deaktiviranje projekta.',
+ 'Unable to open this task.' => 'Nemoguće otvaranje zadatka.',
+ 'Task opened successfully.' => 'Zadatak uspješno otvoren.',
+ 'Unable to close this task.' => 'Nije moguće zatvaranje ovog zadatka.',
+ 'Task closed successfully.' => 'Zadatak uspješno zatvoren.',
+ 'Unable to update your task.' => 'Nije moguće ažuriranje zadatka.',
+ 'Task updated successfully.' => 'Zadatak uspješno ažuriran.',
+ 'Unable to create your task.' => 'Nije moguće kreiranje zadatka.',
+ 'Task created successfully.' => 'Zadatak uspješno kreiran.',
+ 'User created successfully.' => 'Korisnik uspješno kreiran',
+ 'Unable to create your user.' => 'Nije uspjelo kreiranje korisnika.',
+ 'User updated successfully.' => 'Korisnik uspješno ažuriran.',
+ 'Unable to update your user.' => 'Nije moguće ažuriranje korisnika.',
+ 'User removed successfully.' => 'Korisnik uspješno uklonjen.',
+ 'Unable to remove this user.' => 'Nije moguće uklanjanje korisnika.',
+ 'Board updated successfully.' => 'Tabla uspješno ažurirana.',
+ 'Ready' => 'Spreman',
+ 'Backlog' => 'Zaliha',
+ 'Work in progress' => 'U izradi',
+ 'Done' => 'Gotovo',
+ 'Application version:' => 'Verzija aplikacije:',
+ 'Id' => 'Id',
+ '%d closed tasks' => '%d zatvorenih zadataka',
+ 'No task for this project' => 'Nema dodijeljenih zadataka ovom projektu',
+ 'Public link' => 'Javni link',
+ 'Change assignee' => 'Promjena izvršioca',
+ 'Change assignee for the task "%s"' => 'Promjena izvršioca za zadatak "%s"',
+ 'Timezone' => 'Vremenska zona',
+ 'Sorry, I didn\'t find this information in my database!' => 'Na žalost, nije pronađena informacija u bazi',
+ 'Page not found' => 'Strana nije pronađena',
+ 'Complexity' => 'Složenost',
+ 'Task limit' => 'Najviše zadataka',
+ 'Task count' => 'Broj zadataka',
+ 'User' => 'Korisnik',
+ 'Comments' => 'Komentari',
+ 'Write your text in Markdown' => 'Pisanje teksta pomoću Markdown',
+ 'Leave a comment' => 'Ostavi komentar',
+ 'Comment is required' => 'Komentar je obavezan',
+ 'Leave a description' => 'Dodaj opis',
+ 'Comment added successfully.' => 'Komentar uspješno dodan',
+ 'Unable to create your comment.' => 'Nemoguće kreiranje komentara',
+ 'Edit this task' => 'Uredi ovaj zadatak',
+ 'Due Date' => 'Treba biti gotovo do dana',
+ 'Invalid date' => 'Pogrešan datum',
+ 'Automatic actions' => 'Automatske akcije',
+ 'Your automatic action have been created successfully.' => 'Uspješno kreirana automatska akcija',
+ 'Unable to create your automatic action.' => 'Nemoguće kreiranje automatske akcije',
+ 'Remove an action' => 'Obriši akciju',
+ 'Unable to remove this action.' => 'Nije moguće obrisati akciju',
+ 'Action removed successfully.' => 'Akcija obrisana',
+ 'Automatic actions for the project "%s"' => 'Akcije za automatizaciju projekta "%s"',
+ 'Defined actions' => 'Definisane akcje',
+ 'Add an action' => 'dodaj akcju',
+ 'Event name' => 'Naziv događaja',
+ 'Action name' => 'Naziv akcije',
+ 'Action parameters' => 'Parametri akcije',
+ 'Action' => 'Akcija',
+ 'Event' => 'Događaj',
+ 'When the selected event occurs execute the corresponding action.' => 'Na izabrani događaj izvrši odgovarajuću akciju',
+ 'Next step' => 'Slijedeći korak',
+ 'Define action parameters' => 'Definiši parametre akcije',
+ 'Save this action' => 'Snimi akciju',
+ 'Do you really want to remove this action: "%s"?' => 'Da li da obrišem akciju "%s"?',
+ 'Remove an automatic action' => 'Obriši automatsku akciju',
+ 'Assign the task to a specific user' => 'Dodijeli zadatak određenom korisniku',
+ 'Assign the task to the person who does the action' => 'Dodeli zadatak korisniku koji je izvršio akciju',
+ 'Duplicate the task to another project' => 'Kopiraj akciju u drugi projekat',
+ 'Move a task to another column' => 'Premjesti zadatak u drugu kolonu',
+ 'Task modification' => 'Izmjene zadatka',
+ 'Task creation' => 'Kreiranje zadatka',
+ 'Closing a task' => 'Zatvaranja zadatka',
+ 'Assign a color to a specific user' => 'Dodeli boju korisniku',
+ 'Column title' => 'Naslov kolone',
+ 'Position' => 'Pozicija',
+ 'Duplicate to another project' => 'Dupliciraj u drugi projekat',
+ 'Duplicate' => 'Dupliciraj',
+ 'link' => 'link',
+ 'Comment updated successfully.' => 'Komentar uspješno ažuriran.',
+ 'Unable to update your comment.' => 'Neuspješno ažuriranje komentara.',
+ 'Remove a comment' => 'Obriši komentar',
+ 'Comment removed successfully.' => 'Komentar je uspješno obrisan.',
+ 'Unable to remove this comment.' => 'Neuspješno brisanje komentara.',
+ 'Do you really want to remove this comment?' => 'Da li zaista želiš obrisati ovaj komentar?',
+ 'Current password for the user "%s"' => 'Trenutna šifra korisnika "%s"',
+ 'The current password is required' => 'Trenutna šifra je obavezna',
+ 'Wrong password' => 'Pogrešna šifra',
+ 'Unknown' => 'Nepoznat',
+ 'Last logins' => 'Posljednje prijave',
+ 'Login date' => 'Datum prijave',
+ 'Authentication method' => 'Metod autentikacije',
+ 'IP address' => 'IP adresa',
+ 'User agent' => 'Browser',
+ 'Persistent connections' => 'Stalna konekcija',
+ 'No session.' => 'Bez sesije',
+ 'Expiration date' => 'Ističe',
+ 'Remember Me' => 'Zapamti me',
+ 'Creation date' => 'Datum kreiranja',
+ 'Everybody' => 'Svi',
+ 'Open' => 'Otvoreni',
+ 'Closed' => 'Zatvoreni',
+ 'Search' => 'Pretraga',
+ 'Nothing found.' => 'Ništa nije pronađeno',
+ 'Due date' => 'Treba biti gotovo do dana',
+ 'Others formats accepted: %s and %s' => 'Ostali podržani formati: %s i %s',
+ 'Description' => 'Opis',
+ '%d comments' => '%d Komentara',
+ '%d comment' => '%d Komentar',
+ 'Email address invalid' => 'Pogrešan e-mail',
+ 'Your external account is not linked anymore to your profile.' => 'Vaš vanjski korisnički profil nije više povezan.',
+ 'Unable to unlink your external account.' => 'Nemoguće ukloniti vezu s vanjskim korisničkim profilom',
+ 'External authentication failed' => 'Vanjska autentikacija nije uspostavljena',
+ 'Your external account is linked to your profile successfully.' => 'Uspješno uspostavljena vanjska autentikacija',
+ 'Email' => 'E-mail',
+ 'Task removed successfully.' => 'Zadatak uspješno uklonjen.',
+ 'Unable to remove this task.' => 'Nemoguće uklanjanje zadatka.',
+ 'Remove a task' => 'Ukloni zadatak',
+ 'Do you really want to remove this task: "%s"?' => 'Da li zaista želiš ukloniti zadatak "%s"?',
+ 'Assign automatically a color based on a category' => 'Automatski dodijeli boju po kategoriji',
+ 'Assign automatically a category based on a color' => 'Automatski dodijeli kategoriju po boji',
+ 'Task creation or modification' => 'Kreiranje ili izmjena zadatka',
+ 'Category' => 'Kategorija',
+ 'Category:' => 'Kategorija:',
+ 'Categories' => 'Kategorije',
+ 'Category not found.' => 'Kategorija nije pronađena',
+ 'Your category have been created successfully.' => 'Uspješno kreirana kategorija.',
+ 'Unable to create your category.' => 'Nije moguće kreirati kategoriju.',
+ 'Your category have been updated successfully.' => 'Kategorija je uspješno ažurirana',
+ 'Unable to update your category.' => 'Nemoguće izmijeniti kategoriju',
+ 'Remove a category' => 'Ukloni kategoriju',
+ 'Category removed successfully.' => 'Kategorija uspešno uklonjena.',
+ 'Unable to remove this category.' => 'Nije moguće ukloniti kategoriju.',
+ 'Category modification for the project "%s"' => 'Izmjena kategorije za projekat "%s"',
+ 'Category Name' => 'Naziv kategorije',
+ 'Add a new category' => 'Dodaj novu kategoriju',
+ 'Do you really want to remove this category: "%s"?' => 'Da li zaista želiš ukloniti kategoriju: "%s"?',
+ 'All categories' => 'Sve kategorije',
+ 'No category' => 'Bez kategorije',
+ 'The name is required' => 'Naziv je obavezan',
+ 'Remove a file' => 'Ukloni fajl',
+ 'Unable to remove this file.' => 'Fajl nije moguće ukloniti.',
+ 'File removed successfully.' => 'Uspješno uklonjen fajl.',
+ 'Attach a document' => 'Prikači dokument',
+ 'Do you really want to remove this file: "%s"?' => 'Da li da uklonim fajl: "%s"?',
+ 'Attachments' => 'Prilozi',
+ 'Edit the task' => 'Uredi zadatak',
+ 'Edit the description' => 'Uredi opis zadatka',
+ 'Add a comment' => 'Dodaj komentar',
+ 'Edit a comment' => 'Izmijeni komentar',
+ 'Summary' => 'Pregled',
+ 'Time tracking' => 'Praćenje vremena',
+ 'Estimate:' => 'Procjena:',
+ 'Spent:' => 'Potrošeno:',
+ 'Do you really want to remove this sub-task?' => 'Da li da zaista želiš ukloniti pod-zdadatak?',
+ 'Remaining:' => 'Preostalo:',
+ 'hours' => 'sati',
+ 'spent' => 'potrošeno',
+ 'estimated' => 'procijenjeno',
+ 'Sub-Tasks' => 'Pod-zadaci',
+ 'Add a sub-task' => 'Dodaj pod-zadatak',
+ 'Original estimate' => 'Originalna procjena',
+ 'Create another sub-task' => 'Dodaj novi pod-zadatak',
+ 'Time spent' => 'Utrošeno vrijeme',
+ 'Edit a sub-task' => 'Izmijeni pod-zadatak',
+ 'Remove a sub-task' => 'Ukloni pod-zadatak',
+ 'The time must be a numeric value' => 'Vrijeme mora biti broj',
+ 'Todo' => 'Za uraditi',
+ 'In progress' => 'U radu',
+ 'Sub-task removed successfully.' => 'Pod-zadatak uspješno uklonjen.',
+ 'Unable to remove this sub-task.' => 'Nemoguće ukloniti pod-zadatak.',
+ 'Sub-task updated successfully.' => 'Pod-zadatak uspješno ažuriran.',
+ 'Unable to update your sub-task.' => 'Nemoguće ažurirati pod-zadatak.',
+ 'Unable to create your sub-task.' => 'Nemoguće dodati pod-zadatak.',
+ 'Sub-task added successfully.' => 'Pod-zadatak uspješno dodan.',
+ 'Maximum size: ' => 'Maksimalna veličina: ',
+ 'Unable to upload the file.' => 'Nije moguće snimiti fajl.',
+ 'Display another project' => 'Prikaži drugi projekat',
+ 'Created by %s' => 'Kreirao %s',
+ 'Tasks Export' => 'Izvoz zadataka',
+ 'Tasks exportation for "%s"' => 'Izvoz zadataka za "%s"',
+ 'Start Date' => 'Početni datum',
+ 'End Date' => 'Datum završetka',
+ 'Execute' => 'Izvrši',
+ 'Task Id' => 'Identifikator zadatka',
+ 'Creator' => 'Autor',
+ 'Modification date' => 'Datum izmjene',
+ 'Completion date' => 'Datum završetka',
+ 'Clone' => 'Kloniraj',
+ 'Project cloned successfully.' => 'Projekat uspješno kloniran.',
+ 'Unable to clone this project.' => 'Nije moguće klonirati projekat.',
+ 'Enable email notifications' => 'Omogući obavještenja e-mailom',
+ 'Task position:' => 'Pozicija zadatka:',
+ 'The task #%d have been opened.' => 'Zadatak #%d je otvoren.',
+ 'The task #%d have been closed.' => 'Zadatak #%d je zatvoren.',
+ 'Sub-task updated' => 'Pod-zadatak izmijenjen',
+ 'Title:' => 'Naslov:',
+ 'Status:' => 'Status:',
+ 'Assignee:' => 'Izvršilac:',
+ 'Time tracking:' => 'Praćenje vremena:',
+ 'New sub-task' => 'Novi pod-zadatak',
+ 'New attachment added "%s"' => 'Ubačen novi prilog "%s"',
+ 'Comment updated' => 'Komentar ažuriran',
+ 'New comment posted by %s' => '%s ostavio novi komentar',
+ 'New attachment' => 'Novi prilog',
+ 'New comment' => 'Novi komentar',
+ 'New subtask' => 'Novi pod-zadatak',
+ 'Subtask updated' => 'Pod-zadatak ažuriran',
+ 'Task updated' => 'Zadatak ažuriran',
+ 'Task closed' => 'Zadatak je zatvoren',
+ 'Task opened' => 'Zadatak je otvoren',
+ 'I want to receive notifications only for those projects:' => 'Želim obavještenja samo za ove projekte:',
+ 'view the task on Kanboard' => 'Pregledaj zadatke',
+ 'Public access' => 'Javni pristup',
+ 'Active tasks' => 'Aktivni zadaci',
+ 'Disable public access' => 'Zabrani javni pristup',
+ 'Enable public access' => 'Dozvoli javni pristup',
+ 'Public access disabled' => 'Javni pristup onemogućen!',
+ 'Do you really want to disable this project: "%s"?' => 'Da li zaista želiš da deaktiviraš projekat: "%s"?',
+ 'Do you really want to enable this project: "%s"?' => 'Da li zaista želiš da aktiviraš projekat: "%s"?',
+ 'Project activation' => 'Aktivacija projekta',
+ 'Move the task to another project' => 'Premjesti zadatak u drugi projekat',
+ 'Move to another project' => 'Premjesti u drugi projekat',
+ 'Do you really want to duplicate this task?' => 'Da li zaista želiš duplicirati ovaj zadatak?',
+ 'Duplicate a task' => 'Dupliciraj zadatak',
+ 'External accounts' => 'Vanjski korisnički računi',
+ 'Account type' => 'Tip korisničkog računa',
+ 'Local' => 'Lokalno',
+ 'Remote' => 'Udaljeno',
+ 'Enabled' => 'Omogućeno',
+ 'Disabled' => 'Onemogućeno',
+ 'Username:' => 'Korisničko ime:',
+ 'Name:' => 'Ime i Prezime',
+ 'Email:' => 'Email: ',
+ 'Notifications:' => 'Obavještenja: ',
+ 'Notifications' => 'Obavještenja',
+ 'Account type:' => 'Vrsta korisničkog računa:',
+ 'Edit profile' => 'Uredi profil',
+ 'Change password' => 'Promijeni šifru',
+ 'Password modification' => 'Izmjena šifre',
+ 'External authentications' => 'Vanjske autentikacije',
+ 'Never connected.' => 'Bez konekcija.',
+ 'No external authentication enabled.' => 'Bez omogućenih vanjskih autentikacija.',
+ 'Password modified successfully.' => 'Uspješna izmjena šifre.',
+ 'Unable to change the password.' => 'Nije moguće izmijeniti šifru.',
+ 'Change category for the task "%s"' => 'Izijmeni kategoriju zadatka "%s"',
+ 'Change category' => 'Izmijeni kategoriju',
+ '%s updated the task %s' => '%s izmijenio zadatak %s',
+ '%s opened the task %s' => '%s otvorio zadatak %s',
+ '%s moved the task %s to the position #%d in the column "%s"' => '%s premjestio zadatak %s na poziciju #%d u koloni "%s"',
+ '%s moved the task %s to the column "%s"' => '%s premjestio zadatak %s u kolonu "%s"',
+ '%s created the task %s' => '%s kreirao zadatak %s',
+ '%s closed the task %s' => '%s zatvorio zadatak %s',
+ '%s created a subtask for the task %s' => '%s kreirao pod-zadatak zadatka %s',
+ '%s updated a subtask for the task %s' => '%s izmijenio pod-zadatak zadatka %s',
+ 'Assigned to %s with an estimate of %s/%sh' => 'Dodijeljen korisniku %s uz procjenu vremena %s/%sh',
+ 'Not assigned, estimate of %sh' => 'Ne dodijeljen, procijenjeno vrijeme %sh',
+ '%s updated a comment on the task %s' => '%s izmijenio komentar zadatka %s',
+ '%s commented the task %s' => '%s komentarisao zadatak %s',
+ '%s\'s activity' => 'Aktivnosti %s',
+ 'RSS feed' => 'RSS kanal',
+ '%s updated a comment on the task #%d' => '%s izmijenio komentar zadatka #%d',
+ '%s commented on the task #%d' => '%s komentarisao zadatak #%d',
+ '%s updated a subtask for the task #%d' => '%s izmijenio pod-zadatak zadatka #%d',
+ '%s created a subtask for the task #%d' => '%s kreirao pod-zadatak zadatka #%d',
+ '%s updated the task #%d' => '%s ažurirao zadatak #%d',
+ '%s created the task #%d' => '%s kreirao zadatak #%d',
+ '%s closed the task #%d' => '%s zatvorio zadatak #%d',
+ '%s open the task #%d' => '%s otvorio zadatak #%d',
+ '%s moved the task #%d to the column "%s"' => '%s premjestio zadatak #%d u kolonu "%s"',
+ '%s moved the task #%d to the position %d in the column "%s"' => '%s premjestio zadatak #%d na poziciju %d u koloni "%s"',
+ 'Activity' => 'Aktivnosti',
+ 'Default values are "%s"' => 'Podrazumijevane vrijednosti su: "%s"',
+ 'Default columns for new projects (Comma-separated)' => 'Podrazumijevane kolone za novi projekat (Odvojeni zarezom)',
+ 'Task assignee change' => 'Promijena izvršioca zadatka',
+ '%s change the assignee of the task #%d to %s' => '%s zamijeni izvršioca za zadatak #%d u %s',
+ '%s changed the assignee of the task %s to %s' => '%s promijenio izvršioca za zadatak %s u %s',
+ 'New password for the user "%s"' => 'Nova šifra korisnika "%s"',
+ 'Choose an event' => 'Izaberi događaj',
+ 'Create a task from an external provider' => 'Kreiraj zadatak preko posrednika',
+ 'Change the assignee based on an external username' => 'Izmijene izvršioca bazirano na vanjskom korisničkom imenu',
+ 'Change the category based on an external label' => 'Izmijene kategorije bazirano na vanjskoj etiketi',
+ 'Reference' => 'Referenca',
+ 'Label' => 'Etiketa',
+ 'Database' => 'Baza',
+ 'About' => 'O',
+ 'Database driver:' => 'Database driver:',
+ 'Board settings' => 'Postavke table',
+ 'URL and token' => 'URL i token',
+ 'Webhook settings' => 'Postavke za webhook',
+ 'URL for task creation:' => 'URL za kreiranje zadataka',
+ 'Reset token' => 'Resetuj token',
+ 'API endpoint:' => 'API endpoint',
+ 'Refresh interval for private board' => 'Interval osvježavanja privatnih tabli',
+ 'Refresh interval for public board' => 'Interval osvježavanja javnih tabli',
+ 'Task highlight period' => 'Period naznačavanja zadatka',
+ 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Period (u sekundama) u kom su se događale promjene na zadatku (0 je onemogućeno, 2 dana je uobičajeno)',
+ 'Frequency in second (60 seconds by default)' => 'Frekvencija u sekundama (60 sekundi je uobičajeno)',
+ 'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekvencija u sekundama (0 je onemogućeno u budućnosti, 10 sekundi je uobičajeno)',
+ 'Application URL' => 'URL aplikacje',
+ 'Token regenerated.' => 'Token regenerisan.',
+ 'Date format' => 'Format datuma',
+ 'ISO format is always accepted, example: "%s" and "%s"' => 'Format ISO je uvek prihvatljiv, primjer: "%s", "%s"',
+ 'New private project' => 'Novi privatni projekat',
+ 'This project is private' => 'Ovaj projekat je privatan',
+ 'Type here to create a new sub-task' => 'Piši ovdje za kreiranje novog pod-zadatka',
+ 'Add' => 'Dodaj',
+ 'Start date' => 'Datum početka',
+ 'Time estimated' => 'Procijenjeno vrijeme',
+ 'There is nothing assigned to you.' => 'Ništa vam nije dodijeljeno',
+ 'My tasks' => 'Moji zadaci',
+ 'Activity stream' => 'Spisak aktivnosti',
+ 'Dashboard' => 'Panel',
+ 'Confirmation' => 'Potvrda',
+ 'Allow everybody to access to this project' => 'Dozvoli svima pristup ovom projektu',
+ 'Everybody have access to this project.' => 'Svima je dozvoljen pristup ovom projektu.',
+ 'Webhooks' => 'Webhooks',
+ 'API' => 'API',
+ 'Create a comment from an external provider' => 'Napravi komentar preko vanjskog posrednika',
+ 'Project management' => 'Upravljanje projektima',
+ 'My projects' => 'Moji projekti',
+ 'Columns' => 'Kolone',
+ 'Task' => 'Zadatak',
+ 'Your are not member of any project.' => 'Nisi član ni jednog projekta',
+ 'Percentage' => 'Procenat',
+ 'Number of tasks' => 'Broj zadataka',
+ 'Task distribution' => 'Podjela zadataka',
+ 'Reportings' => 'Izveštaji',
+ 'Task repartition for "%s"' => 'Zaduženja zadataka za "%s"',
+ 'Analytics' => 'Analiza',
+ 'Subtask' => 'Pod-zadatak',
+ 'My subtasks' => 'Moji pod-zadaci',
+ 'User repartition' => 'Zaduženja korisnika',
+ 'User repartition for "%s"' => 'Zaduženja korisnika za "%s"',
+ 'Clone this project' => 'Kloniraj ovaj projekat',
+ 'Column removed successfully.' => 'Kolona uspješno uklonjena.',
+ 'Not enough data to show the graph.' => 'Nedovoljno podataka za prikaz na grafikonu.',
+ 'Previous' => 'Prethodni',
+ 'The id must be an integer' => 'ID mora biti cjeloviti broj',
+ 'The project id must be an integer' => 'ID projekta mora biti cjeloviti broj',
+ 'The status must be an integer' => 'Status mora biti cjeloviti broj',
+ 'The subtask id is required' => 'ID pod-zadataka je obavezan',
+ 'The subtask id must be an integer' => 'ID pod-zadatka mora biti cjeloviti broj',
+ 'The task id is required' => 'ID zadatka je obavezan',
+ 'The task id must be an integer' => 'ID zadatka mora biti cjeloviti broj',
+ 'The user id must be an integer' => 'ID korisnika mora biti cjeloviti broj',
+ 'This value is required' => 'Vrijednost je obavezna',
+ 'This value must be numeric' => 'Vrijednost mora biti broj',
+ 'Unable to create this task.' => 'Nije moguće kreirati zadatak.',
+ 'Cumulative flow diagram' => 'Zbirni dijagram toka',
+ 'Cumulative flow diagram for "%s"' => 'Zbirni dijagram toka za "%s"',
+ 'Daily project summary' => 'Zbirni pregled po danima',
+ 'Daily project summary export' => 'Izvoz zbirnog pregleda po danima',
+ 'Daily project summary export for "%s"' => 'Izvoz zbirnog pregleda po danima za "%s"',
+ 'Exports' => 'Izvozi',
+ 'This export contains the number of tasks per column grouped per day.' => 'Ovaj izvoz sadržava broj zadataka po koloni grupisanih po danima.',
+ 'Nothing to preview...' => 'Ništa za pokazati...',
+ 'Preview' => 'Pregled',
+ 'Write' => 'Piši',
+ 'Active swimlanes' => 'Aktivne swimline trake',
+ 'Add a new swimlane' => 'Dodaj novu swimline traku',
+ 'Change default swimlane' => 'Preimenuj podrazumijevanu swimline traku',
+ 'Default swimlane' => 'Podrazumijevana swimline traka',
+ 'Do you really want to remove this swimlane: "%s"?' => 'Da li zaista želiš ukloniti ovu swimline traku: "%s"?',
+ 'Inactive swimlanes' => 'Neaktivne swimline trake',
+ 'Remove a swimlane' => 'Ukloni swimline traku',
+ 'Show default swimlane' => 'Prikaži podrazumijevanu swimline traku',
+ 'Swimlane modification for the project "%s"' => 'Izmjene swimline trake za projekat "%s"',
+ 'Swimlane not found.' => 'Swimline traka nije pronađena.',
+ 'Swimlane removed successfully.' => 'Swimline traka uspješno uklonjena.',
+ 'Swimlanes' => 'Swimline trake',
+ 'Swimlane updated successfully.' => 'Swimline traka uspjeno ažurirana.',
+ 'The default swimlane have been updated successfully.' => 'Podrazumijevana swimline traka uspješno ažurirana.',
+ 'Unable to remove this swimlane.' => 'Nemoguće ukloniti swimline traku.',
+ 'Unable to update this swimlane.' => 'Nemoguće ažurirati swimline traku.',
+ 'Your swimlane have been created successfully.' => 'Swimline traka je uspješno kreirana.',
+ 'Example: "Bug, Feature Request, Improvement"' => 'Npr: "Greška, Zahtjev za izmjenama, Poboljšanje"',
+ 'Default categories for new projects (Comma-separated)' => 'Podrazumijevane kategorije za novi projekat',
+ 'Integrations' => 'Integracije',
+ 'Integration with third-party services' => 'Integracija sa uslugama vanjskih servisa',
+ 'Subtask Id' => 'ID pod-zadatka',
+ 'Subtasks' => 'Pod-zadaci',
+ 'Subtasks Export' => 'Izvoz pod-zadataka',
+ 'Subtasks exportation for "%s"' => 'Izvoz pod-zadataka za "%s"',
+ 'Task Title' => 'Naslov zadatka',
+ 'Untitled' => 'Bez naslova',
+ 'Application default' => 'Podrazumijevano od aplikacije',
+ 'Language:' => 'Jezik:',
+ 'Timezone:' => 'Vremenska zona:',
+ 'All columns' => 'Sve kolone',
+ 'Calendar' => 'Kalendar',
+ 'Next' => 'Slijedeći',
+ '#%d' => '#%d',
+ 'All swimlanes' => 'Sve swimline trake',
+ 'All colors' => 'Sve boje',
+ 'Moved to column %s' => 'Premješten u kolonu %s',
+ 'Change description' => 'Promijeni opis',
+ 'User dashboard' => 'Korisnički panel',
+ 'Allow only one subtask in progress at the same time for a user' => 'Dozvoli samo jedan pod-zadatak "u radu" po korisniku',
+ 'Edit column "%s"' => 'Uredi kolonu "%s"',
+ 'Select the new status of the subtask: "%s"' => 'Izaberi novi status za pod-zadatak: "%s"',
+ 'Subtask timesheet' => 'Vremenska tabela za pod-zadatak',
+ 'There is nothing to show.' => 'Nema ništa za pokazati',
+ 'Time Tracking' => 'Praćenje vremena',
+ 'You already have one subtask in progress' => 'Već imaš jedan pod-zadatak "u radu"',
+ 'Which parts of the project do you want to duplicate?' => 'Koje delove projekta želiš duplicirati?',
+ 'Disallow login form' => 'Zabrani prijavnu formu',
+ 'Start' => 'Početak',
+ 'End' => 'Kraj',
+ 'Task age in days' => 'Trajanje zadatka u danima',
+ 'Days in this column' => 'Dani u ovoj koloni',
+ '%dd' => '%dd',
+ 'Add a link' => 'Dodaj vezu',
+ 'Add a new link' => 'Dodaj novu vezu',
+ 'Do you really want to remove this link: "%s"?' => 'Da li zaista želite ukloniti ovu vezu: "%s"?',
+ 'Do you really want to remove this link with task #%d?' => 'Da li zaista želite ukloniti ovu vezu sa zadatkom #%d?',
+ 'Field required' => 'Polje je obavezno',
+ 'Link added successfully.' => 'Veza je uspješno dodana.',
+ 'Link updated successfully.' => 'Veza je uspješno ažurirana.',
+ 'Link removed successfully.' => 'Veza je uspješno uklonjena.',
+ 'Link labels' => 'Veza s etiketama',
+ 'Link modification' => 'Veza modifikacija',
+ 'Links' => 'Veze',
+ 'Link settings' => 'Postavke veza',
+ 'Opposite label' => 'Suprotna etiketa',
+ 'Remove a link' => 'Ukloni vezu',
+ 'Task\'s links' => 'Veze zadatka',
+ 'The labels must be different' => 'Etikete moraju biti različite',
+ 'There is no link.' => 'Ovdje nema veza',
+ 'This label must be unique' => 'Ova etiketa mora biti jedinstvena',
+ 'Unable to create your link.' => 'Nemoguće napraviti vezu.',
+ 'Unable to update your link.' => 'Nemoguće ažurirati vezu.',
+ 'Unable to remove this link.' => 'Nemoguće ukloniti vezu.',
+ 'relates to' => 'relacija sa',
+ 'blocks' => 'blokira',
+ 'is blocked by' => 'je blokiran od',
+ 'duplicates' => 'duplicira',
+ 'is duplicated by' => 'je dupliciran od',
+ 'is a child of' => 'je dijete od',
+ 'is a parent of' => 'je roditelj od',
+ 'targets milestone' => 'cilj prekretnice',
+ 'is a milestone of' => 'je od prekretnice',
+ 'fixes' => 'popravlja',
+ 'is fixed by' => 'je popravljen od',
+ 'This task' => 'Ovaj zadatak',
+ '<1h' => '<1h',
+ '%dh' => '%dh',
+ 'Expand tasks' => 'Proširi zadatke',
+ 'Collapse tasks' => 'Skupi zadatke',
+ 'Expand/collapse tasks' => 'Proširi/skupi zadatke',
+ 'Close dialog box' => 'Skupi dialog',
+ 'Submit a form' => 'Pošalji obrazac',
+ 'Board view' => 'Pregled ploče',
+ 'Keyboard shortcuts' => 'Prečice tastature',
+ 'Open board switcher' => 'Otvori prekidače ploče',
+ 'Application' => 'Aplikacija',
+ 'Compact view' => 'Kompaktan pregled',
+ 'Horizontal scrolling' => 'Horizontalno listanje',
+ 'Compact/wide view' => 'Skupi/raširi pregled',
+ 'No results match:' => 'Nema rezultata:',
+ 'Currency' => 'Valuta',
+ 'Private project' => 'Privatni projekat',
+ 'AUD - Australian Dollar' => 'AUD - Australijski dolar',
+ 'CAD - Canadian Dollar' => 'CAD - Kanadski dolar',
+ 'CHF - Swiss Francs' => 'CHF - Švicarski franak',
+ 'Custom Stylesheet' => 'Prilagođeni stil',
+ 'download' => 'preuzmi',
+ 'EUR - Euro' => 'EUR - Evro',
+ 'GBP - British Pound' => 'GBP - Britanska funta',
+ 'INR - Indian Rupee' => 'INR - Indijski rupi',
+ 'JPY - Japanese Yen' => 'JPY - Japanski jen',
+ 'NZD - New Zealand Dollar' => 'NZD - Novozelandski dolar',
+ 'RSD - Serbian dinar' => 'RSD - Srpski dinar',
+ 'USD - US Dollar' => 'USD - Američki dolar',
+ 'Destination column' => 'Odredišna kolona',
+ 'Move the task to another column when assigned to a user' => 'Premjesti zadatak u neku drugu kolonu kada se dodijeli izvršiocu',
+ 'Move the task to another column when assignee is cleared' => 'Premjesti zadatak u neku drugu kolonu kada se ukloni izvršilac',
+ 'Source column' => 'Izvorna kolona',
+ 'Transitions' => 'Prelaz',
+ 'Executer' => 'Izvršilac',
+ 'Time spent in the column' => 'Vrijeme provedeno u koloni',
+ 'Task transitions' => 'Prelazi zadatka',
+ 'Task transitions export' => 'Izvezi prelaze zadatka',
+ 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Ovaj izvještaj sadržava sve kolone premještanja za svaki zadatak s datumom, te korisnikom i utrošenim vremenom za svaki premještaj.',
+ 'Currency rates' => 'Stopa valute',
+ 'Rate' => 'Stopa',
+ 'Change reference currency' => 'Promijeni referencu valute',
+ 'Add a new currency rate' => 'Dodaj novu stopu valute',
+ 'Reference currency' => 'Referenca valute',
+ 'The currency rate have been added successfully.' => 'Stopa valute je uspješno dodana.',
+ 'Unable to add this currency rate.' => 'Nemoguće dodati stopu valute.',
+ 'Webhook URL' => 'Webhook URL',
+ '%s remove the assignee of the task %s' => '%s je uklonio izvršioca zadatka %s',
+ 'Enable Gravatar images' => 'Omogući Gravatar slike',
+ 'Information' => 'Informacije',
+ 'Check two factor authentication code' => 'Provjera faktor-dva autentifikacionog koda',
+ 'The two factor authentication code is not valid.' => 'Faktor-dva autentifikacionog koda nije validan.',
+ 'The two factor authentication code is valid.' => 'Faktor-dva autentifikacionog koda je validan.',
+ 'Code' => 'Kod',
+ 'Two factor authentication' => 'Faktor-dva autentifikacija',
+ 'This QR code contains the key URI: ' => 'Ovaj QR kod sadržava ključni URL: ',
+ 'Check my code' => 'Provjeri moj kod',
+ 'Secret key: ' => 'Tajni ključ: ',
+ 'Test your device' => 'Testiraj svoj uređaj',
+ 'Assign a color when the task is moved to a specific column' => 'Dodijeli boju kada je zadatak pomjeren u odabranu kolonu',
+ '%s via Kanboard' => '%s uz pomoć Kanboard-a',
+ 'Burndown chart for "%s"' => 'Grafikon izgaranja za "%s"',
+ 'Burndown chart' => 'Grafikon izgaranja',
+ 'This chart show the task complexity over the time (Work Remaining).' => 'Ovaj grafikon pokazuje kompleksnost zadatka u vremenu (Preostalo vremena)',
+ 'Screenshot taken %s' => 'Slika ekrana uzeta %s',
+ 'Add a screenshot' => 'Dodaj sliku ekrana',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Uzmi sliku ekrana i pritisni CTRL+V ili ⌘+V da zalijepiš ovdje.',
+ 'Screenshot uploaded successfully.' => 'Slika ekrana uspješno dodana.',
+ 'SEK - Swedish Krona' => 'SEK - Švedska kruna',
+ 'Identifier' => 'Identifikator',
+ 'Disable two factor authentication' => 'Onemogući faktor-dva autentifikaciju',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Da li zaista želiš onemogućiti faktor-dva autentifikaciju: "%s"?',
+ 'Edit link' => 'Uredi vezu',
+ 'Start to type task title...' => 'Počni pisati naslov zadatka...',
+ 'A task cannot be linked to itself' => 'Zadatak ne može biti povezan sa samim sobom',
+ 'The exact same link already exists' => 'Ista veza već postoji',
+ 'Recurrent task is scheduled to be generated' => 'Ponavljajući zadatak je pripremljen da bude kreiran',
+ 'Score' => 'Uspjeh',
+ 'The identifier must be unique' => 'Identifikator mora biti jedinstven',
+ 'This linked task id doesn\'t exists' => 'Povezani ID zadatka ne postoji',
+ 'This value must be alphanumeric' => 'Ova vrijednost mora biti alfanumerička',
+ 'Edit recurrence' => 'Uredi ponavljanje',
+ 'Generate recurrent task' => 'Napravi ponavljajući zadatak',
+ 'Trigger to generate recurrent task' => 'Okidač koji pravi ponavljajući zadatak',
+ 'Factor to calculate new due date' => 'Faktor za računanje novog datuma završetka',
+ 'Timeframe to calculate new due date' => 'Vremenski okvir za računanje novog datuma završetka',
+ 'Base date to calculate new due date' => 'Početni datum za računanje novog datuma završetka',
+ 'Action date' => 'Datum akcije',
+ 'Base date to calculate new due date: ' => 'Početni datum za računanje novog datuma završetka: ',
+ 'This task has created this child task: ' => 'Ovaj zadatak će napraviti zadatak-dijete: ',
+ 'Day(s)' => 'Dan(i)',
+ 'Existing due date' => 'Postojeći datum završetka',
+ 'Factor to calculate new due date: ' => 'Faktor za računanje novog datuma završetka: ',
+ 'Month(s)' => 'Mjesec(i)',
+ 'Recurrence' => 'Referenca',
+ 'This task has been created by: ' => 'Ovaj zadatak je napravio: ',
+ 'Recurrent task has been generated:' => 'Ponavljajući zadatak je napravio:',
+ 'Timeframe to calculate new due date: ' => 'Vremenski okvir za računanje novog datuma završetka:',
+ 'Trigger to generate recurrent task: ' => 'Okidač za pravljenje ponavljajućeg zadatka',
+ 'When task is closed' => 'Kada je zadatak zatvoren',
+ 'When task is moved from first column' => 'Kada je zadatak premješten iz prve kolone',
+ 'When task is moved to last column' => 'Kada je zadatak premješten u posljednju kolonu',
+ 'Year(s)' => 'Godina/e',
+ 'Calendar settings' => 'Postavke kalendara',
+ 'Project calendar view' => 'Pregled kalendara projekta',
+ 'Project settings' => 'Postavke projekta',
+ 'Show subtasks based on the time tracking' => 'Prikaži pod-zadatke bazirano na vremenskom praćenju',
+ 'Show tasks based on the creation date' => 'Prikaži zadatke bazirano na vremenu otvaranja',
+ 'Show tasks based on the start date' => 'Prikaži zadatke bazirano na vremenu početka rada',
+ 'Subtasks time tracking' => 'Vremensko praćenje pod-zadataka',
+ 'User calendar view' => 'Pregled korisničkog kalendara',
+ 'Automatically update the start date' => 'Automatski ažuriraj početni datum',
+ 'iCal feed' => 'iCal kanal',
+ 'Preferences' => 'Postavke',
+ 'Security' => 'Sigurnost',
+ 'Two factor authentication disabled' => 'Faktor-dva autentifikacija onemogućena',
+ 'Two factor authentication enabled' => 'Faktor-dva autentifikacija omogućena',
+ 'Unable to update this user.' => 'Nemoguće ažurirati ovog korisnika',
+ 'There is no user management for private projects.' => 'Nema mehanizma za upravljanje korisnicima kod privatnih projekata.',
+ 'User that will receive the email' => 'Korisnik će dobiti email',
+ 'Email subject' => 'Predmet email-a',
+ 'Date' => 'Datum',
+ 'Add a comment log when moving the task between columns' => 'Dodaj komentar u dnevnik kada se pomjeri zadatak između kolona',
+ 'Move the task to another column when the category is changed' => 'Pomjeri zadatak u drugu kolonu kada je kategorija promijenjena',
+ 'Send a task by email to someone' => 'Pošalji zadatak nekome emailom',
+ 'Reopen a task' => 'Ponovo otvori zadatak',
+ 'Column change' => 'Promijena kolone',
+ 'Position change' => 'Promjena pozicije',
+ 'Swimlane change' => 'Promjena swimline trake',
+ 'Assignee change' => 'Promijenjen izvršilac',
+ '[%s] Overdue tasks' => '[%s] Zaostali zadaci',
+ 'Notification' => 'Obavještenja',
+ '%s moved the task #%d to the first swimlane' => '%s je premjestio zadatak #%d u prvu swimline traku',
+ '%s moved the task #%d to the swimlane "%s"' => '%s je premjestio zadatak #%d u swimline traku "%s"',
+ 'Swimlane' => 'Swimline traka',
+ 'Gravatar' => 'Gravatar',
+ '%s moved the task %s to the first swimlane' => '%s je premjestio zadatak %s u prvi swimline traku',
+ '%s moved the task %s to the swimlane "%s"' => '%s je premjestio zadatak %s u swimline traku "%s"',
+ 'This report contains all subtasks information for the given date range.' => 'Ovaj izvještaj sadržava sve informacije o pod-zadacima za dati period',
+ 'This report contains all tasks information for the given date range.' => 'Ovaj izvještaj sadržava sve informacije o zadacima u datom periodu',
+ 'Project activities for %s' => 'Aktivnosti projekta za %s',
+ 'view the board on Kanboard' => 'pregled ploče na Kanboard-u',
+ 'The task have been moved to the first swimlane' => 'Zadatak je premješten u prvu swimline traku',
+ 'The task have been moved to another swimlane:' => 'Zadatak je premješten u drugu swimline traku',
+ 'Overdue tasks for the project "%s"' => 'Zadaci u kašnjenju za projekat "%s"',
+ 'New title: %s' => 'Novi naslov: %s',
+ 'The task is not assigned anymore' => 'Zadatak nema više izvršioca',
+ 'New assignee: %s' => 'Novi izvršilac: %s',
+ 'There is no category now' => 'Sada nema kategorije',
+ 'New category: %s' => 'Nova kategorija: %s',
+ 'New color: %s' => 'Nova boja: %s',
+ 'New complexity: %d' => 'Nova složenost: %d',
+ 'The due date have been removed' => 'Datum završetka je ukloljen',
+ 'There is no description anymore' => 'Nema više opisa',
+ 'Recurrence settings have been modified' => 'Promijenjene postavke za ponavljajuće zadatke',
+ 'Time spent changed: %sh' => 'Utrošeno vrijeme je promijenjeno: %sh',
+ 'Time estimated changed: %sh' => 'Očekivano vrijeme je promijenjeno: %sh',
+ 'The field "%s" have been updated' => 'Polje "%s" je ažurirano',
+ 'The description have been modified' => 'Promijenjen opis',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'Da li zaista želiš zatvoriti zadatak "%s" kao i sve pod-zadatke?',
+ 'I want to receive notifications for:' => 'Želim dobijati obavještenja za:',
+ 'All tasks' => 'Sve zadatke',
+ 'Only for tasks assigned to me' => 'Samo za zadatke na kojima sam izvršilac',
+ 'Only for tasks created by me' => 'Samo za zadatke koje sam ja napravio',
+ 'Only for tasks created by me and assigned to me' => 'Samo za zadatke koje sam ja napravio i na kojima sam izvršilac',
+ '%%Y-%%m-%%d' => '%%Y-%%m-%%d',
+ 'Total for all columns' => 'Ukupno za sve kolone',
+ 'You need at least 2 days of data to show the chart.' => 'Da bi se prikazao ovaj grafik potrebni su podaci iz najmanje posljednja dva dana.',
+ '<15m' => '<15m',
+ '<30m' => '<30m',
+ 'Stop timer' => 'Zaustavi tajmer',
+ 'Start timer' => 'Pokreni tajmer',
+ 'Add project member' => 'Dodaj člana projekta',
+ 'Enable notifications' => 'Omogući obavještenja',
+ 'My activity stream' => 'Tok mojih aktivnosti',
+ 'My calendar' => 'Moj kalendar',
+ 'Search tasks' => 'Pretraga zadataka',
+ 'Back to the calendar' => 'Vrati na kalendar',
+ 'Filters' => 'Filteri',
+ 'Reset filters' => 'Vrati filtere na početno',
+ 'My tasks due tomorrow' => 'Moji zadaci koje treba završiti sutra',
+ 'Tasks due today' => 'Zadaci koje treba završiti danas',
+ 'Tasks due tomorrow' => 'Zadaci koje treba završiti sutra',
+ 'Tasks due yesterday' => 'Zadaci koje je trebalo završiti jučer',
+ 'Closed tasks' => 'Zatvoreni zadaci',
+ 'Open tasks' => 'Otvoreni zadaci',
+ 'Not assigned' => 'Bez izvršioca',
+ 'View advanced search syntax' => 'Vidi naprednu sintaksu pretrage',
+ 'Overview' => 'Opšti pregled',
+ 'Board/Calendar/List view' => 'Pregle Table/Kalendara/Liste',
+ 'Switch to the board view' => 'Promijeni da vidim tablu',
+ 'Switch to the calendar view' => 'Promijeni da vidim kalendar',
+ 'Switch to the list view' => 'Promijeni da vidim listu',
+ 'Go to the search/filter box' => 'Idi na kutiju s pretragom/filterima',
+ 'There is no activity yet.' => 'Još uvijek nema aktivnosti.',
+ 'No tasks found.' => 'Zadaci nisu pronađeni.',
+ 'Keyboard shortcut: "%s"' => 'Prečica tastature: "%s"',
+ 'List' => 'Lista',
+ 'Filter' => 'Filter',
+ 'Advanced search' => 'Napredna pretraga',
+ 'Example of query: ' => 'Primjer za upit',
+ 'Search by project: ' => 'Pretraga po projektu',
+ 'Search by column: ' => 'Pretraga po koloni',
+ 'Search by assignee: ' => 'Pretraga po izvršiocu',
+ 'Search by color: ' => 'Pretraga po boji',
+ 'Search by category: ' => 'Pretraga po kategoriji',
+ 'Search by description: ' => 'Pretraga po opisu',
+ 'Search by due date: ' => 'Pretraga po datumu završetka',
+ 'Lead and Cycle time for "%s"' => 'Vrijeme upravljanje i vremenski ciklus za "%s"',
+ 'Average time spent into each column for "%s"' => 'Prosjek utrošenog vremena u svakoj koloni za "%s"',
+ 'Average time spent into each column' => 'Prosjek utrošenog vrmena u svakoj koloni',
+ 'Average time spent' => 'Prosjek utrošenog vremena',
+ 'This chart show the average time spent into each column for the last %d tasks.' => 'Ovaj grafik pokazuje prosjek utrošenog vremena u svakoj koloni za posljednjih %d zadataka.',
+ 'Average Lead and Cycle time' => 'Prosjek vremena upravljanja i vremenskog ciklusa',
+ 'Average lead time: ' => 'Prosjek vremena upravljanja',
+ 'Average cycle time: ' => 'Prosjek vremenskog ciklusa',
+ 'Cycle Time' => 'Vremenski ciklus',
+ 'Lead Time' => 'Vrijeme upravljanja',
+ 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Ovaj grafik pokazuje prosjek vremena vođenja i vremenskog ciklusa za posljednjih %d zadataka tokom vremena.',
+ 'Average time into each column' => 'Prosječno vrijeme u svakoj koloni',
+ 'Lead and cycle time' => 'Vrijeme vođenja i vremenski ciklus',
+ 'Lead time: ' => 'Vrijeme vođenja: ',
+ 'Cycle time: ' => 'Vremenski ciklus: ',
+ 'Time spent into each column' => 'Utrošeno vrijeme u svakoj koloni',
+ 'The lead time is the duration between the task creation and the completion.' => 'Vrijeme vođenja je vrijeme koje je proteklo između otvaranja i zatvaranja zadatka.',
+ 'The cycle time is the duration between the start date and the completion.' => 'Vremenski ciklus je vrijeme koje je proteklo između početka i završetka rada na zadatku.',
+ 'If the task is not closed the current time is used instead of the completion date.' => 'Ako zadatak nije zatvoren trenutno vrijeme je iskorišteno umjesto datuma završetka.',
+ 'Set automatically the start date' => 'Automatski postavi početno vrijeme',
+ 'Edit Authentication' => 'Uredi autentifikaciju',
+ 'Remote user' => 'Vanjski korisnik',
+ 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Vanjski korisnik ne čuva šifru u Kanboard bazi, npr: LDAP, Google i Github korisnički računi.',
+ 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Ako ste označili kvadratić "Zabrani prijavnu formu", unos pristupnih podataka u prijavnoj formi će biti ignorisan.',
+ 'New remote user' => 'Novi vanjski korisnik',
+ 'New local user' => 'Novi lokalni korisnik',
+ 'Default task color' => 'Podrazumijevana boja zadatka',
+ 'This feature does not work with all browsers.' => 'Ovaj funkcionalnost ne radi na svim internet pretraživačima.',
+ 'There is no destination project available.' => 'Nema definisanog odredišta za projekat.',
+ 'Trigger automatically subtask time tracking' => 'Okidač za automatsko vremensko praćenje za pod-zadatke',
+ 'Include closed tasks in the cumulative flow diagram' => 'Obuhvati zatvorene zadatke u kumulativnom dijagramu toka',
+ 'Current swimlane: %s' => 'Trenutna swimline traka: %s',
+ 'Current column: %s' => 'Trenutna kolona: %s',
+ 'Current category: %s' => 'Trenutna kategorija: %s',
+ 'no category' => 'bez kategorije',
+ 'Current assignee: %s' => 'Trenutni izvršilac: %s',
+ 'not assigned' => 'bez ivršioca',
+ 'Author:' => 'Autor:',
+ 'contributors' => 'saradnici',
+ 'License:' => 'Licenca:',
+ 'License' => 'Licenca',
+ 'Enter the text below' => 'Unesi tekst ispod',
+ 'Gantt chart for %s' => 'Gantogram za %s',
+ 'Sort by position' => 'Sortiraj po poziciji',
+ 'Sort by date' => 'Sortiraj po datumu',
+ 'Add task' => 'Dodaj zadatak',
+ 'Start date:' => 'Početno vrijeme:',
+ 'Due date:' => 'Vrijeme do kada treba završiti:',
+ 'There is no start date or due date for this task.' => 'Nema početnog datuma ili datuma do kada treba završiti ovaj zadatak.',
+ 'Moving or resizing a task will change the start and due date of the task.' => 'Premještanje ili promjena veličine zadatka će promijeniti datum početka i datum do kada treba završiti zadatak.',
+ 'There is no task in your project.' => 'Nema zadataka u tvom projektu.',
+ 'Gantt chart' => 'Gantogram',
+ 'People who are project managers' => 'Osobe koji su menadžeri projekta',
+ 'People who are project members' => 'Osobe koje su članovi projekta',
+ 'NOK - Norwegian Krone' => 'NOK - Norveška kruna',
+ 'Show this column' => 'Prikaži ovu kolonu',
+ 'Hide this column' => 'Sakrij ovu kolonu',
+ 'open file' => 'otvori fajl',
+ 'End date' => 'Datum završetka',
+ 'Users overview' => 'Opšti pregled korisnika',
+ 'Members' => 'Članovi',
+ 'Shared project' => 'Dijeljeni projekti',
+ 'Project managers' => 'Menadžeri projekta',
+ 'Gantt chart for all projects' => 'Gantogram za sve projekte',
+ 'Projects list' => 'Lista projekata',
+ 'Gantt chart for this project' => 'Gantogram za ovaj projekat',
+ 'Project board' => 'Tabla projekta',
+ 'End date:' => 'Datum završetka:',
+ 'There is no start date or end date for this project.' => 'Nema početnog ili krajnjeg datuma za ovaj projekat.',
+ 'Projects Gantt chart' => 'Gantogram projekata',
+ 'Link type' => 'Tip veze',
+ 'Change task color when using a specific task link' => 'Promijeni boju zadatka kada se koristi određena veza na zadatku',
+ 'Task link creation or modification' => 'Veza na zadatku je napravljena ili izmijenjena',
+ 'Milestone' => 'Prekretnica',
+ 'Documentation: %s' => 'Dokumentacija: %s',
+ 'Switch to the Gantt chart view' => 'Promijeni u gantogram pregled',
+ 'Reset the search/filter box' => 'Vrati na početno pretragu/filtere',
+ 'Documentation' => 'Dokumentacija',
+ 'Table of contents' => 'Sadržaj',
+ 'Gantt' => 'Gantogram',
+ 'Author' => 'Autor',
+ 'Version' => 'Verzija',
+ 'Plugins' => 'Dodaci',
+ 'There is no plugin loaded.' => 'Nema učitanih dodataka.',
+ 'Set maximum column height' => 'Postavi maksimalnu visinu kolone',
+ 'Remove maximum column height' => 'Ukloni maksimalnu visinu kolone',
+ 'My notifications' => 'Moja obavještenja',
+ 'Custom filters' => 'Prilagođeni filteri',
+ 'Your custom filter have been created successfully.' => 'Tvoj prilagođeni filter je uspješno napravljen.',
+ 'Unable to create your custom filter.' => 'Nemoguće napraviti prilagođeni filter.',
+ 'Custom filter removed successfully.' => 'Prilagođeni filter uspješno uklonjen.',
+ 'Unable to remove this custom filter.' => 'Nemoguće ukloniti prilagođeni filter.',
+ 'Edit custom filter' => 'Uredi prilagođeni filter',
+ 'Your custom filter have been updated successfully.' => 'Prilagođeni filter uspješno ažuriran.',
+ 'Unable to update custom filter.' => 'Nemoguće ažurirati prilagođeni filter',
+ 'Web' => 'Web',
+ 'New attachment on task #%d: %s' => 'Novi priložak na zadatku #%d: %s',
+ 'New comment on task #%d' => 'Novi komentar na zadatku #%d',
+ 'Comment updated on task #%d' => 'Ažuriran komentar na zadatku #%d',
+ 'New subtask on task #%d' => 'Novi pod-zadatak na zadatku #%d',
+ 'Subtask updated on task #%d' => 'Pod-zadatak ažuriran na zadatku #%d',
+ 'New task #%d: %s' => 'Novi zadatak #%d: %s',
+ 'Task updated #%d' => 'Zadatak ažuriran #%d',
+ 'Task #%d closed' => 'Zadatak #%d zatvoren',
+ 'Task #%d opened' => 'Zadatak #%d otvoren',
+ 'Column changed for task #%d' => 'Promijenjena kolona za zadatak #%d',
+ 'New position for task #%d' => 'Nova pozicija za zadatak #%d',
+ 'Swimlane changed for task #%d' => 'Swimline traka promijenjena za zadatak #%d',
+ 'Assignee changed on task #%d' => 'Promijenjen izvršilac na zadatku #%d',
+ '%d overdue tasks' => '%d zadataka kasni',
+ 'Task #%d is overdue' => 'Zadatak #%d kasni',
+ 'No new notifications.' => 'Nema novi obavještenja.',
+ 'Mark all as read' => 'Označi sve kao pročitano',
+ 'Mark as read' => 'Označi kao pročitano',
+ 'Total number of tasks in this column across all swimlanes' => 'Ukupan broj zadataka u ovoj koloni u svim swimline trakama',
+ 'Collapse swimlane' => 'Skupi swimline trake',
+ 'Expand swimlane' => 'Proširi swimline trake',
+ 'Add a new filter' => 'Dodaj novi filter',
+ 'Share with all project members' => 'Podijeli s svim članovima projekta',
+ 'Shared' => 'Podijeljeno',
+ 'Owner' => 'Vlasnik',
+ 'Unread notifications' => 'Nepročitana obavještenja',
+ 'My filters' => 'Moji filteri',
+ 'Notification methods:' => 'Metode obavještenja:',
+ 'Import tasks from CSV file' => 'Uvezi zadatke putem CSV fajla',
+ 'Unable to read your file' => 'Nemoguće pročitati fajl',
+ '%d task(s) have been imported successfully.' => '%d zadataka uspješno uvezeno.',
+ 'Nothing have been imported!' => 'Ništa nije uvezeno!',
+ 'Import users from CSV file' => 'Uvezi korisnike putem CSV fajla',
+ '%d user(s) have been imported successfully.' => '%d korisnika uspješno uvezeno.',
+ 'Comma' => 'Zarez',
+ 'Semi-colon' => 'Tačka-zarez',
+ 'Tab' => 'Tab',
+ 'Vertical bar' => 'Vertikalna traka',
+ 'Double Quote' => 'Dvostruki navodnici',
+ 'Single Quote' => 'Jednostruki navodnici',
+ '%s attached a file to the task #%d' => '%s je dodao novi fajl u zadatak %d',
+ 'There is no column or swimlane activated in your project!' => 'Nema kolone ili swimline trake aktivirane za ovaj projekat!',
+ 'Append filter (instead of replacement)' => 'Dodaj filter (umjesto zamjene postojećeg)',
+ 'Append/Replace' => 'Dodaj/Zamijeni',
+ 'Append' => 'Dodaj',
+ 'Replace' => 'Zamijeni',
+ 'Import' => 'Uvoz',
+ 'change sorting' => 'Promijeni sortiranje',
+ 'Tasks Importation' => 'Uvoz zadataka',
+ 'Delimiter' => 'Djelilac',
+ 'Enclosure' => 'Prilog',
+ 'CSV File' => 'CSV File',
+ 'Instructions' => 'Uputstva',
+ 'Your file must use the predefined CSV format' => 'File mora biti predefinisani CSV format',
+ 'Your file must be encoded in UTF-8' => 'File mora biti u UTF-8 kodu',
+ 'The first row must be the header' => 'Prvi red mora biti zaglavlje',
+ 'Duplicates are not verified for you' => 'Dipliciranje nisu potvrđena',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'Datum do kog se treba izvršiti mora biti u ISO formatu: GGGG-MM-DD',
+ 'Download CSV template' => 'Preuzmi CSV šablon',
+ 'No external integration registered.' => 'Nema registrovanih vanjskih integracija.',
+ 'Duplicates are not imported' => 'Duplikati nisu uvezeni',
+ 'Usernames must be lowercase and unique' => 'Korisničko ime mora biti malim slovima i jedinstveno',
+ 'Passwords will be encrypted if present' => 'Šifra će biti kriptovana',
+ // '%s attached a new file to the task %s' => '',
+ 'Assign automatically a category based on a link' => 'Automatsko pridruživanje kategorije bazirano na vezi',
+ 'BAM - Konvertible Mark' => 'BAM - Konvertibilna marka',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ 'Project members' => 'Članovi projekta',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
+);
diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php
index 2631ab2a..f03d70c5 100644
--- a/app/Locale/cs_CZ/translations.php
+++ b/app/Locale/cs_CZ/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Vyjmout projekt',
'Edit the board for "%s"' => 'Editace nástěnky pro "%s" ',
'All projects' => 'Všechny projekty',
- 'Change columns' => 'Změna sloupců',
'Add a new column' => 'Přidat nový sloupec',
'Title' => 'Název',
'Nobody assigned' => 'Nepřiřazena žádná osoba',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Otevřít úkol',
'Do you really want to open this task: "%s"?' => 'Opravdu chcete znovuotevřít tento úkol: "%s"?',
'Back to the board' => 'Zpět na nástěnku',
- 'Created on %B %e, %Y at %k:%M %p' => 'Vytvořeno dne %d.%m.%Y v čase %H:%M',
'There is nobody assigned' => 'Není přiřazeno žádnému uživateli',
'Column on the board:' => 'Sloupec:',
- 'Status is open' => 'Status je otevřený',
- 'Status is closed' => 'Status je uzavřený',
'Close this task' => 'Uzavřít úkol',
'Open this task' => 'Aufgabe wieder öffnen',
'There is no description.' => 'Bez popisu',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'ID je vyžadováno',
'The project id is required' => 'ID projektu je vyžadováno',
'The project name is required' => 'Jméno projektu je vyžadováno',
- 'This project must be unique' => 'Jméno projektu musí být jedinečné',
'The title is required' => 'Nadpis je vyžadován',
'Settings saved successfully.' => 'Nastavení bylo úspěšně uloženo',
'Unable to save your settings.' => 'Vaše nastavení nelze uložit.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'V řešení',
'Done' => 'Dokončeno',
'Application version:' => 'Verze:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Dokončeno %d.%m.%Y v %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d.%m.%Y v %H:%M',
- 'Date created' => 'Datum vytvoření',
- 'Date completed' => 'Datum dokončení',
'Id' => 'ID',
'%d closed tasks' => '%d dokončených úkolů',
'No task for this project' => 'Tento projekt nemá žádné úkoly',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Složitost',
'Task limit' => 'Maximální počet úkolů',
'Task count' => 'Počet úkolů',
- 'Edit project access list' => 'Upravit přístupový seznam projektu',
- 'Allow this user' => 'Povolit tomuto uživateli',
- 'Don\'t forget that administrators have access to everything.' => 'Nezapomeňte, že administrátoři mají přistup ke všem údajům.',
- 'Revoke' => 'Odebrat',
- 'List of authorized users' => 'Seznam autorizovaných uživatelů',
'User' => 'Uživatel',
- 'Nobody have access to this project.' => 'Nikdo nemá přístup k tomuto projektu.',
'Comments' => 'Komentáře',
'Write your text in Markdown' => 'Můžete použít i Markdown-syntaxi',
'Leave a comment' => 'Zanechte komentář',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Editace úkolu',
'Due Date' => 'Datum splnění',
'Invalid date' => 'Neplatné datum',
- 'Must be done before %B %e, %Y' => 'Musí být dokončeno do %d.%m.%Y ',
- '%B %e, %Y' => '%d.%m.%Y',
- // '%b %e, %Y' => '',
'Automatic actions' => 'Automaticky vykonávané akce',
'Your automatic action have been created successfully.' => 'Vaše akce byla úspěšně vytvořena.',
'Unable to create your automatic action.' => 'Vaší akci nebylo možné vytvořit.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Přiřadit barvu konkrétnímu uživateli',
'Column title' => 'Název sloupce',
'Position' => 'Pozice',
- 'Move Up' => 'Posunout nahoru',
- 'Move Down' => 'Posunout dolu',
'Duplicate to another project' => 'Vytvořit kopii v jiném projektu',
'Duplicate' => 'Vytvořit kopii',
'link' => 'Link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Komentář byl smazán.',
'Unable to remove this comment.' => 'Komentář nelze odebrat.',
'Do you really want to remove this comment?' => 'Skutečně chcete odebrat tento komentář?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Přístup k této stránce mají pouze administrátoři nebo vlastníci komentáře.',
'Current password for the user "%s"' => 'Aktuální heslo pro uživatele "%s"',
'The current password is required' => 'Heslo je vyžadováno',
'Wrong password' => 'Neplatné heslo',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'E-Mail',
- 'Link my Google Account' => 'Propojit s Google účtem',
- 'Unlink my Google Account' => 'Odpojit Google účet',
- 'Login with my Google Account' => 'Přihlášení pomocí Google účtu',
- 'Project not found.' => 'Projekt nebyl nalezen.',
'Task removed successfully.' => 'Úkol byl úspěšně odebrán.',
'Unable to remove this task.' => 'Tento úkol nelze odebrat.',
'Remove a task' => 'Odebrat úkol',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maximální velikost: ',
'Unable to upload the file.' => 'Soubor nelze nahrát.',
'Display another project' => 'Zobrazit jiný projekt',
- // 'Login with my Github Account' => '',
- // 'Link my Github Account' => '',
- // 'Unlink my Github Account' => '',
'Created by %s' => 'Vytvořeno uživatelem %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Poslední úprava dne %d.%m.%Y v čase %H:%M',
'Tasks Export' => 'Export úkolů',
'Tasks exportation for "%s"' => 'Export úkolů pro "%s"',
'Start Date' => 'Počáteční datum',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Přeji si dostávat upozornění pouze pro následující projekty:',
'view the task on Kanboard' => 'Zobrazit úkol na Kanboard',
'Public access' => 'Veřejný přístup',
- 'User management' => 'Správa uživatelů',
'Active tasks' => 'Aktivní úkoly',
'Disable public access' => 'Zakázat veřejný přístup',
'Enable public access' => 'Povolit veřejný přístup',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'e-mail',
'Notifications:' => 'Upozornění:',
'Notifications' => 'Upozornění',
- 'Group:' => 'Skupina',
- 'Regular user' => 'Pravidelný uživatel',
'Account type:' => 'Typ účtu:',
'Edit profile' => 'Upravit profil',
'Change password' => 'Změnit heslo',
'Password modification' => 'Změna hesla',
'External authentications' => 'Vzdálená autorizace',
- 'Google Account' => 'Google účet',
- 'Github Account' => 'github účet',
'Never connected.' => 'Zatím nikdy nespojen.',
- 'No account linked.' => 'Žádné propojení účtu.',
- 'Account linked.' => 'Propojení účtu',
'No external authentication enabled.' => 'Není povolena žádná vzdálená autorizace.',
'Password modified successfully.' => 'Heslo bylo úspěšně změněno.',
'Unable to change the password.' => 'Nelze změnit heslo.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s změnil řešitele úkolu %s na uživatele %s',
'New password for the user "%s"' => 'Nové heslo pro uživatele "%s"',
'Choose an event' => 'Vybrat událost',
- 'Github commit received' => 'Github commit empfangen',
- 'Github issue opened' => 'Github Fehler geöffnet',
- 'Github issue closed' => 'Github Fehler geschlossen',
- 'Github issue reopened' => 'Github Fehler erneut geöffnet',
- 'Github issue assignee change' => 'Github Fehlerzuständigkeit geändert',
- 'Github issue label change' => 'Github Fehlerkennzeichnung verändert',
'Create a task from an external provider' => 'Vytvořit úkol externím poskytovatelem',
'Change the assignee based on an external username' => 'Změna přiřazení uživatele závislá na externím uživateli',
'Change the category based on an external label' => 'Změna kategorie závislá na externím popisku',
'Reference' => 'Reference',
- 'Reference: %s' => 'Reference: %s',
// 'Label' => '',
'Database' => 'Datenbank',
'About' => 'O projektu',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frekvence v sekundách (60 sekund ve výchozím nastavení)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekvence v sekundách (0 pro zákaz této vlastnosti, 10 sekund ve výchozím nastavení)',
'Application URL' => 'URL aplikace',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Ukázka: http://example.kanboard.net/ (použit v emailovém upozornění)',
'Token regenerated.' => 'Token byl opětovně generován.',
'Date format' => 'Formát datumu',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO formát je vždy akceptován, například: "%s" a "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Tento projekt je soukromuý',
'Type here to create a new sub-task' => 'Uveďte zde pro vytvoření nového dílčího úkolu',
'Add' => 'Přidat',
- 'Estimated time: %s hours' => 'Předpokládaný čas: %s hodin',
- 'Time spent: %s hours' => 'Doba trvání: %s hodin',
- 'Started on %B %e, %Y' => 'Zahájeno %B %e %Y',
'Start date' => 'Počáteční datum',
'Time estimated' => 'Odhadovaný čas',
'There is nothing assigned to you.' => 'Nemáte přiřazenou žádnou položku.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Přístup k tomuto projektu má kdokoliv.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github Webhook',
- 'Help on Github webhooks' => 'Hilfe für Github Webhooks',
'Create a comment from an external provider' => 'Vytvořit komentář pomocí externího poskytovatele',
- 'Github issue comment created' => 'Github Fehler Kommentar hinzugefügt',
'Project management' => 'Správa projektů',
'My projects' => 'Moje projekty',
'Columns' => 'Sloupce',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Rozdělení podle uživatelů pro "%s"',
'Clone this project' => 'Duplokovat projekt',
'Column removed successfully.' => 'Sloupec byl odstraněn.',
- 'Github Issue' => 'Github Issue',
'Not enough data to show the graph.' => 'Pro zobrazení grafu není dostatek dat.',
'Previous' => 'Předchozí',
'The id must be an integer' => 'ID musí být celé číslo',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Výchozí Swimlane',
'Do you really want to remove this swimlane: "%s"?' => 'Diese Swimlane wirklich ändern: "%s"?',
'Inactive swimlanes' => 'Inaktive Swimlane',
- 'Set project manager' => 'Nastavit práva vedoucího projektu',
- 'Set project member' => 'Nastavit práva člena projektu',
'Remove a swimlane' => 'Odstranit swimlane',
- 'Rename' => 'Přejmenovat',
'Show default swimlane' => 'Standard Swimlane anzeigen',
'Swimlane modification for the project "%s"' => 'Swimlane Änderung für das Projekt "%s"',
'Swimlane not found.' => 'Swimlane nicht gefunden',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane erfolgreich geändert.',
'The default swimlane have been updated successfully.' => 'Die standard Swimlane wurden erfolgreich aktualisiert. Die standard Swimlane wurden erfolgreich aktualisiert.',
- 'Unable to create your swimlane.' => 'Es ist nicht möglich die Swimlane zu erstellen.',
'Unable to remove this swimlane.' => 'Es ist nicht möglich die Swimlane zu entfernen.',
'Unable to update this swimlane.' => 'Es ist nicht möglich die Swimöane zu ändern.',
'Your swimlane have been created successfully.' => 'Die Swimlane wurde erfolgreich angelegt.',
'Example: "Bug, Feature Request, Improvement"' => 'Beispiel: "Bug, Funktionswünsche, Verbesserung"',
'Default categories for new projects (Comma-separated)' => 'Výchozí kategorie pro nové projekty (oddělené čárkou)',
- 'Gitlab commit received' => 'Gitlab commit erhalten',
- 'Gitlab issue opened' => 'Gitlab Fehler eröffnet',
- 'Gitlab issue closed' => 'Gitlab Fehler geschlossen',
- 'Gitlab webhooks' => 'Gitlab Webhook',
- 'Help on Gitlab webhooks' => 'Hilfe für Gitlab Webhooks',
'Integrations' => 'Integrace',
'Integration with third-party services' => 'Integration von Fremdleistungen',
- 'Role for this project' => 'Role pro tento projekt',
- 'Project manager' => 'Projektový manažer',
- 'Project member' => 'Člen projektu',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Manažer projektu může změnit nastavení projektu a zároveň má více práv než standardní uživatel.',
- 'Gitlab Issue' => 'Gitlab Fehler',
'Subtask Id' => 'Dílčí úkol Id',
'Subtasks' => 'Dílčí úkoly',
'Subtasks Export' => 'Export dílčích úkolů',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Jeden dílčí úkol již aktuálně řešíte',
'Which parts of the project do you want to duplicate?' => 'Které části projektu chcete duplikovat?',
// 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Bitbucket commit erhalten',
- 'Bitbucket webhooks' => 'Bitbucket webhooks',
- 'Help on Bitbucket webhooks' => 'Hilfe für Bitbucket webhooks',
'Start' => 'Začátek',
'End' => 'Konec',
'Task age in days' => 'Doba trvání úkolu ve dnech',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Tento úkol',
'<1h' => '<1h',
'%dh' => '%dh',
- // '%b %e' => '',
'Expand tasks' => 'Rozpbalit úkoly',
'Collapse tasks' => 'Sbalit úkoly',
'Expand/collapse tasks' => 'Rozbalit / sbalit úkoly',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Klávesnicové zkratky',
'Open board switcher' => 'Otevřít přepínač nástěnek',
'Application' => 'Aplikace',
- 'since %B %e, %Y at %k:%M %p' => 'dne %d.%m.%Y v čase %H:%M',
'Compact view' => 'Kompaktní zobrazení',
'Horizontal scrolling' => 'Horizontální rolování',
'Compact/wide view' => 'Kompaktní/plné zobrazení',
'No results match:' => 'Žádná shoda:',
'Currency' => 'Měna',
- 'Files' => 'Soubory',
- 'Images' => 'Obrázky',
'Private project' => 'Soukromý projekt',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'Der Zwei-Faktor-Authentifizierungscode ist gültig.',
'Code' => 'Code',
'Two factor authentication' => 'Dvouúrovňová autorizace',
- 'Enable/disable two factor authentication' => 'Povolit / zakázat dvou úrovňovou autorizaci',
'This QR code contains the key URI: ' => 'Dieser QR-Code beinhaltet die Schlüssel-URI',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Speichere den geheimen Schlüssel in deiner TOTP software (z.B. Google Authenticator oder FreeOTP).',
'Check my code' => 'Kontrola mého kódu',
'Secret key: ' => 'Tajný klíč',
'Test your device' => 'Test Vašeho zařízení',
'Assign a color when the task is moved to a specific column' => 'Přiřadit barvu, když je úkol přesunut do konkrétního sloupce',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'Nahráno uživatelem: %s',
- 'uploaded on: %s' => 'Nahráno dne: %s',
- 'size: %s' => 'Velikost: %s',
'Burndown chart for "%s"' => 'Burndown-Chart für "%s"',
'Burndown chart' => 'Burndown-Chart',
'This chart show the task complexity over the time (Work Remaining).' => 'Graf zobrazuje složitost úkolů v čase (Zbývající práce).',
@@ -722,7 +645,6 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Nimm einen Screenshot auf und drücke STRG+V oder ⌘+V um ihn hier einzufügen.',
'Screenshot uploaded successfully.' => 'Screenshot erfolgreich hochgeladen.',
'SEK - Swedish Krona' => 'SEK - Schwedische Kronen',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Identifikátor projektu je volitelný alfanumerický kód používaný k identifikaci vašeho projektu.',
'Identifier' => 'Identifikator',
'Disable two factor authentication' => 'Zrušit dvou stupňovou autorizaci',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Willst du wirklich für folgenden Nutzer die Zwei-Faktor-Authentifizierung deaktivieren: "%s"?',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
'User that will receive the email' => 'Uživatel, který dostane E-mail',
'Email subject' => 'E-mail Předmět',
'Date' => 'Datum',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
'Add a comment log when moving the task between columns' => 'Přidat komentář když je úkol přesouván mezi sloupci',
'Move the task to another column when the category is changed' => 'Přesun úkolu do jiného sloupce když je změněna kategorie',
'Send a task by email to someone' => 'Poslat někomu úkol poštou',
'Reopen a task' => 'Znovu otevřít úkol',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
'Column change' => 'Spalte geändert',
'Position change' => 'Position geändert',
'Swimlane change' => 'Swimlane geändert',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'Das Feld "%s" wurde verändert',
'The description have been modified' => 'Die Beschreibung wurde geändert',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Soll die Aufgabe "%s" wirklich geschlossen werden? (einschließlich Teilaufgaben)',
- // 'Swimlane: %s' => '',
'I want to receive notifications for:' => 'Chci dostávat upozornění na:',
'All tasks' => 'Všechny úkoly',
'Only for tasks assigned to me' => 'pouze pro moje úkoly',
'Only for tasks created by me' => 'pouze pro mnou vytvořené úkoly',
'Only for tasks created by me and assigned to me' => 'pouze pro mnou vytvořené a mě přiřazené úkoly',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- 'New due date: %B %e, %Y' => 'Neues Ablaufdatum: %B %e, %Y',
- 'Start date changed: %B %e, %Y' => 'Neues Beginndatum: %B %e, %Y',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
'Total for all columns' => 'S',
'You need at least 2 days of data to show the chart.' => 'Potřebujete nejméně data ze dvou dnů pro zobrazení grafu',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Nepřiřazené',
'View advanced search syntax' => 'Zobrazit syntaxi rozšířeného vyhledávání',
'Overview' => 'Přehled',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Nástěnka/Kalendář/Zobrazení seznamu',
'Switch to the board view' => 'Přepnout na nástěnku',
'Switch to the calendar view' => 'Přepnout na kalendář',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Graf ukazuje průměrnou dodací lhůtu a dobu cyklu pro posledních %d úkolů v průběhu času',
'Average time into each column' => 'Průměrná doba v každé fázi',
'Lead and cycle time' => 'Dodací lhůta a doba cyklu',
- 'Google Authentication' => 'Ověřování pomocí služby Google',
- 'Help on Google authentication' => 'Nápověda k ověřování pomocí služby Google',
- 'Github Authentication' => 'Ověřování pomocí služby Github',
- 'Help on Github authentication' => 'Nápověda k ověřování pomocí služby Github',
'Lead time: ' => 'Dodací lhůta: ',
'Cycle time: ' => 'Doba cyklu: ',
'Time spent into each column' => 'Čas strávený v každé fázi',
@@ -906,18 +805,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Jestliže není úkol uzavřen, místo termínu dokončení je použit aktuální čas.',
'Set automatically the start date' => 'Nastavit automaticky počáteční datum',
'Edit Authentication' => 'Upravit ověřování',
- 'Google Id' => 'Google ID',
- 'Github Id' => 'Github ID',
'Remote user' => 'Vzdálený uživatel',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Hesla vzdáleným uživatelům se neukládají do databáze Kanboard. Naříklad: LDAP, Google a Github účty.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Pokud zaškrtnete políčko "Zakázat přihlašovací formulář", budou pověření zadané do přihlašovacího formuláře ignorovány.',
- 'By @%s on Gitlab' => 'uživatelem @%s na Gitlab',
- 'Gitlab issue comment created' => 'Vytvořen komentář problému na Gitlab',
'New remote user' => 'Nový vzdálený uživatel',
'New local user' => 'Nový lokální uživatel',
'Default task color' => 'Výchozí barva úkolu',
- 'Hide sidebar' => 'Schovat postranní panel',
- 'Expand sidebar' => 'Rozbalit postranní panel',
'This feature does not work with all browsers.' => 'Tato funkcionalita nefunguje ve všech prohlížečích.',
'There is no destination project available.' => 'Není dostupný žádný cílový projekt.',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'přispěvatelé',
'License:' => 'Licence:',
'License' => 'Licence',
- 'Project Administrator' => 'Administrátor projektu',
'Enter the text below' => 'Zadejte text níže',
'Gantt chart for %s' => 'Gantt graf pro %s',
'Sort by position' => 'Třídit podle pozice',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php
index 6d221a7a..03180b5a 100644
--- a/app/Locale/da_DK/translations.php
+++ b/app/Locale/da_DK/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Fjern projekt',
'Edit the board for "%s"' => 'Rediger boardet for "%s"',
'All projects' => 'Alle Projekter',
- 'Change columns' => 'Ændre kolonner',
'Add a new column' => 'Tilføj en ny kolonne',
'Title' => 'Titel',
'Nobody assigned' => 'Ingen ansvarlig',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Åben en opgave',
'Do you really want to open this task: "%s"?' => 'Vil du virkelig åbne denne opgave: "%s"?',
'Back to the board' => 'Tilbage til boardet',
- 'Created on %B %e, %Y at %k:%M %p' => 'Oprettet %d.%m.%Y - %H:%M',
'There is nobody assigned' => 'Der er ingen tilføjet',
'Column on the board:' => 'Kolonne:',
- 'Status is open' => 'Status er åbnet',
- 'Status is closed' => 'Status er lukket',
'Close this task' => 'Luk denne opgave',
'Open this task' => 'Åben denne opgave',
'There is no description.' => 'Der er ingen beskrivning.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Id\'et er krævet',
'The project id is required' => 'Projektets id er krævet',
'The project name is required' => 'Projektets navn er krævet',
- 'This project must be unique' => 'Projektets navn skal være unikt',
'The title is required' => 'Titel er krævet',
'Settings saved successfully.' => 'Indstillinger gemt.',
'Unable to save your settings.' => 'Indstillinger kunne ikke gemmes.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Igangværende',
'Done' => 'Færdig',
'Application version:' => 'Version:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Fuldført %d.%m.%Y - %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d.%m.%Y - %H:%M',
- 'Date created' => 'Dato for oprettelse',
- 'Date completed' => 'Dato for fuldført',
'Id' => 'ID',
'%d closed tasks' => '%d lukket opgavet',
'No task for this project' => 'Ingen opgaver i dette projekt',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Kompleksitet',
'Task limit' => 'Opgave begrænsning',
// 'Task count' => '',
- 'Edit project access list' => 'Rediger adgangstilladelser for projektet',
- 'Allow this user' => 'Tillad denne bruger',
- 'Don\'t forget that administrators have access to everything.' => 'Glem ikke at administratorer har adgang til alt.',
- 'Revoke' => 'Fjern',
- 'List of authorized users' => 'Liste over autoriserede brugere',
'User' => 'Bruger',
- 'Nobody have access to this project.' => 'Ingen har adgang til dette projekt.',
'Comments' => 'Kommentarer',
'Write your text in Markdown' => 'Skriv din tekst i markdown',
'Leave a comment' => 'Efterlad en kommentar',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Rediger denne opgave',
'Due Date' => 'Forfaldsdato',
'Invalid date' => 'Ugyldig dato',
- 'Must be done before %B %e, %Y' => 'Skal være fuldført inden %d.%m.%Y',
- '%B %e, %Y' => '%d.%m.%Y',
- // '%b %e, %Y' => '',
'Automatic actions' => 'Automatiske handlinger',
'Your automatic action have been created successfully.' => 'Din automatiske handling er oprettet.',
'Unable to create your automatic action.' => 'Din automatiske handling kunne ikke oprettes.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Tildel en farve til en bestemt bruger',
'Column title' => 'Kolonne titel',
'Position' => 'Position',
- 'Move Up' => 'Ryk op',
- 'Move Down' => 'Ryk ned',
'Duplicate to another project' => 'Kopier til et andet projekt',
'Duplicate' => 'Kopier',
'link' => 'link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Kommentaren blev fjernet.',
'Unable to remove this comment.' => 'Kommentaren kunne ikke fjernes.',
'Do you really want to remove this comment?' => 'Vil du virkelig fjerne denne kommentar?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Kun administratore eller brugeren, som har oprettet kommentaren har adgang til denne side.',
'Current password for the user "%s"' => 'Aktuelle adgangskode for brugeren "%s"',
'The current password is required' => 'Den aktuelle adgangskode er krævet',
'Wrong password' => 'Forkert adgangskode',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'E-Mail',
- 'Link my Google Account' => 'Forbind min Google-konto',
- 'Unlink my Google Account' => 'Fjern forbindelsen til min Google-konto',
- 'Login with my Google Account' => 'Login med min Google-konto',
- 'Project not found.' => 'Projekt ikke fundet.',
'Task removed successfully.' => 'Opgaven er fjernet.',
'Unable to remove this task.' => 'Opgaven kunne ikke fjernes.',
'Remove a task' => 'Fjern en opgave',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maksimum størrelse: ',
'Unable to upload the file.' => 'Filen kunne ikke uploades.',
'Display another project' => 'Vis et andet projekt...',
- 'Login with my Github Account' => 'Login med min Github-konto',
- 'Link my Github Account' => 'Forbind min Github-konto',
- 'Unlink my Github Account' => 'Fjern forbindelsen til min Github-konto',
'Created by %s' => 'Oprettet af %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Sidst redigeret %d.%m.%Y - %H:%M',
'Tasks Export' => 'Opgave eksport',
'Tasks exportation for "%s"' => 'Opgave eksport for "%s"',
'Start Date' => 'Start-dato',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Jeg vil kun have notifikationer for disse projekter:',
'view the task on Kanboard' => 'se opgaven på Kanboard',
'Public access' => 'Offentlig adgang',
- 'User management' => 'Brugerstyring',
'Active tasks' => 'Aktive opgaver',
'Disable public access' => 'Deaktiver offentlig adgang',
'Enable public access' => 'Aktivér offentlig adgang',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Email:',
'Notifications:' => 'Notifikationer:',
'Notifications' => 'Notifikationer',
- 'Group:' => 'Gruppe:',
- 'Regular user' => 'Normal bruger',
'Account type:' => 'Konto type:',
'Edit profile' => 'Rediger profil',
'Change password' => 'Skift adgangskode',
'Password modification' => 'Adgangskode ændring',
'External authentications' => 'Ekstern autentificering',
- 'Google Account' => 'Google-konto',
- 'Github Account' => 'Github-konto',
'Never connected.' => 'Aldrig forbundet.',
- 'No account linked.' => 'Ingen kontoer forfundet.',
- 'Account linked.' => 'Konto forbundet.',
'No external authentication enabled.' => 'Ingen eksterne autentificering aktiveret.',
'Password modified successfully.' => 'Adgangskode ændret.',
'Unable to change the password.' => 'Adgangskoden kunne ikke ændres.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s skift ansvarlig for opgaven %s til %s',
'New password for the user "%s"' => 'Ny adgangskode for brugeren "%s"',
'Choose an event' => 'Vælg et event',
- 'Github commit received' => 'Github commit modtaget',
- 'Github issue opened' => 'Github problem åbet',
- 'Github issue closed' => 'Github problem lukket',
- 'Github issue reopened' => 'Github problem genåbnet',
- 'Github issue assignee change' => 'Github problem ansvarlig skift',
- 'Github issue label change' => 'Github problem label skift',
'Create a task from an external provider' => 'Opret en opgave fra en ekstern udbyder',
'Change the assignee based on an external username' => 'Skift den ansvarlige baseret på et eksternt brugernavn',
'Change the category based on an external label' => 'Skift kategorien baseret på en ekstern label',
'Reference' => 'Reference',
- 'Reference: %s' => 'Reference: %s',
'Label' => 'Label',
'Database' => 'Database',
'About' => 'Om',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frekevens i sekunder (60 sekunder som standard)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekvens i sekunder (0 for at deaktivere denne funktion, 10 sekunder som standard)',
'Application URL' => 'Applikation URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Eksempel: http://example.kanboard.net/ (bruges til email notifikationer)',
'Token regenerated.' => 'Token regenereret.',
'Date format' => 'Dato format',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO format er altid accepteret, eksempelvis: "%s" og "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Dette projekt er privat',
'Type here to create a new sub-task' => 'Skriv her for at tilføje en ny under-opgave',
'Add' => 'Tilføj',
- 'Estimated time: %s hours' => 'Estimeret tid: %s timer',
- 'Time spent: %s hours' => 'Tid brugt: %s timer',
- 'Started on %B %e, %Y' => 'Startet %d.%m.%Y ',
'Start date' => 'Start dato',
'Time estimated' => 'Tid estimeret',
'There is nothing assigned to you.' => 'Der er ingenting tildelt til dig.',
@@ -496,10 +449,7 @@ return array(
// 'Everybody have access to this project.' => '',
// 'Webhooks' => '',
// 'API' => '',
- // 'Github webhooks' => '',
- // 'Help on Github webhooks' => '',
// 'Create a comment from an external provider' => '',
- // 'Github issue comment created' => '',
// 'Project management' => '',
// 'My projects' => '',
// 'Columns' => '',
@@ -517,7 +467,6 @@ return array(
// 'User repartition for "%s"' => '',
// 'Clone this project' => '',
// 'Column removed successfully.' => '',
- // 'Github Issue' => '',
// 'Not enough data to show the graph.' => '',
// 'Previous' => '',
// 'The id must be an integer' => '',
@@ -547,10 +496,7 @@ return array(
// '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.' => '',
@@ -558,24 +504,13 @@ return array(
// '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)' => '',
- // 'Gitlab commit received' => '',
- // 'Gitlab issue opened' => '',
- // 'Gitlab issue closed' => '',
- // 'Gitlab webhooks' => '',
- // 'Help on 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' => '',
// 'Subtask Id' => '',
// 'Subtasks' => '',
// 'Subtasks Export' => '',
@@ -603,9 +538,6 @@ return array(
// 'You already have one subtask in progress' => '',
// 'Which parts of the project do you want to duplicate?' => '',
// 'Disallow login form' => '',
- // 'Bitbucket commit received' => '',
- // 'Bitbucket webhooks' => '',
- // 'Help on Bitbucket webhooks' => '',
// 'Start' => '',
// 'End' => '',
// 'Task age in days' => '',
@@ -646,7 +578,6 @@ return array(
// 'This task' => '',
// '<1h' => '',
// '%dh' => '',
- // '%b %e' => '',
// 'Expand tasks' => '',
// 'Collapse tasks' => '',
// 'Expand/collapse tasks' => '',
@@ -656,14 +587,11 @@ return array(
// 'Keyboard shortcuts' => '',
// 'Open board switcher' => '',
// 'Application' => '',
- // 'since %B %e, %Y at %k:%M %p' => '',
// 'Compact view' => '',
// 'Horizontal scrolling' => '',
// 'Compact/wide view' => '',
// 'No results match:' => '',
// 'Currency' => '',
- // 'Files' => '',
- // 'Images' => '',
// 'Private project' => '',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
@@ -703,17 +631,12 @@ return array(
// 'The two factor authentication code is valid.' => '',
// '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' => '',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
// 'Burndown chart for "%s"' => '',
// 'Burndown chart' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
// 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
// 'Send a task by email to someone' => '',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
// 'Column change' => '',
// 'Position change' => '',
// 'Swimlane change' => '',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
// 'I want to receive notifications for:' => '',
// 'All tasks' => '',
// 'Only for tasks assigned to me' => '',
// 'Only for tasks created by me' => '',
// 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
// 'Not assigned' => '',
// 'View advanced search syntax' => '',
// 'Overview' => '',
- // '%b %e %Y' => '',
// 'Board/Calendar/List view' => '',
// 'Switch to the board view' => '',
// 'Switch to the calendar view' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
// 'New remote user' => '',
// 'New local user' => '',
// 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
// 'contributors' => '',
// 'License:' => '',
// 'License' => '',
- // 'Project Administrator' => '',
// 'Enter the text below' => '',
// 'Gantt chart for %s' => '',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php
index e566c0f6..a1ec2604 100644
--- a/app/Locale/de_DE/translations.php
+++ b/app/Locale/de_DE/translations.php
@@ -20,15 +20,15 @@ return array(
'Red' => 'Rot',
'Orange' => 'Orange',
'Grey' => 'Grau',
- 'Brown' => 'Bran',
+ 'Brown' => 'Braun',
'Deep Orange' => 'Dunkelorange',
'Dark Grey' => 'Dunkelgrau',
'Pink' => 'Pink',
'Teal' => 'Türkis',
'Cyan' => 'Cyan',
- 'Lime' => 'Grün (lime)',
+ 'Lime' => 'Limette',
'Light Green' => 'Hellgrün',
- 'Amber' => 'Braun (Amber)',
+ 'Amber' => 'Bernstein',
'Save' => 'Speichern',
'Login' => 'Anmelden',
'Official website:' => 'Offizielle Webseite:',
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Projekt löschen',
'Edit the board for "%s"' => 'Pinnwand für "%s" bearbeiten',
'All projects' => 'Alle Projekte',
- 'Change columns' => 'Spalten ändern',
'Add a new column' => 'Neue Spalte hinzufügen',
'Title' => 'Titel',
'Nobody assigned' => 'Nicht zugeordnet',
@@ -96,17 +95,14 @@ return array(
'Edit a task' => 'Aufgabe bearbeiten',
'Column' => 'Spalte',
'Color' => 'Farbe',
- 'Assignee' => 'Zuständig',
+ 'Assignee' => 'Zuständiger',
'Create another task' => 'Weitere Aufgabe erstellen',
'New task' => 'Neue Aufgabe',
'Open a task' => 'Öffne eine Aufgabe',
'Do you really want to open this task: "%s"?' => 'Soll diese Aufgabe wirklich wieder geöffnet werden: "%s"?',
'Back to the board' => 'Zurück zur Pinnwand',
- 'Created on %B %e, %Y at %k:%M %p' => 'Erstellt am %d.%m.%Y um %H:%M',
'There is nobody assigned' => 'Die Aufgabe wurde niemandem zugewiesen',
'Column on the board:' => 'Spalte:',
- 'Status is open' => 'Status ist geöffnet',
- 'Status is closed' => 'Status ist geschlossen',
'Close this task' => 'Aufgabe schließen',
'Open this task' => 'Aufgabe wieder öffnen',
'There is no description.' => 'Keine Beschreibung vorhanden.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Die ID ist anzugeben',
'The project id is required' => 'Die Projekt ID ist anzugeben',
'The project name is required' => 'Der Projektname ist anzugeben',
- 'This project must be unique' => 'Der Projektname muss eindeutig sein',
'The title is required' => 'Der Titel ist anzugeben',
'Settings saved successfully.' => 'Einstellungen erfolgreich gespeichert.',
'Unable to save your settings.' => 'Speichern der Einstellungen nicht möglich.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'In Arbeit',
'Done' => 'Erledigt',
'Application version:' => 'Version:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Abgeschlossen am %d.%m.%Y um %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d.%m.%Y um %H:%M',
- 'Date created' => 'Erstellt am',
- 'Date completed' => 'Abgeschlossen am',
'Id' => 'ID',
'%d closed tasks' => '%d abgeschlossene Aufgaben',
'No task for this project' => 'Keine Aufgaben in diesem Projekt',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Komplexität',
'Task limit' => 'Maximale Anzahl von Aufgaben',
'Task count' => 'Aufgabenanzahl',
- 'Edit project access list' => 'Zugriffsberechtigungen des Projektes bearbeiten',
- 'Allow this user' => 'Diesen Benutzer autorisieren',
- 'Don\'t forget that administrators have access to everything.' => 'Nicht vergessen: Administratoren haben überall Zugriff.',
- 'Revoke' => 'Entfernen',
- 'List of authorized users' => 'Liste der autorisierten Benutzer',
'User' => 'Benutzer',
- 'Nobody have access to this project.' => 'Niemand hat Zugriff auf dieses Projekt.',
'Comments' => 'Kommentare',
'Write your text in Markdown' => 'Schreibe deinen Text in Markdown-Syntax',
'Leave a comment' => 'Kommentar eingeben',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Aufgabe bearbeiten',
'Due Date' => 'Fällig am',
'Invalid date' => 'Ungültiges Datum',
- 'Must be done before %B %e, %Y' => 'Muss vor dem %d.%m.%Y erledigt werden',
- '%B %e, %Y' => '%d.%m.%Y',
- '%b %e, %Y' => '%d.%m.%Y',
'Automatic actions' => 'Automatische Aktionen',
'Your automatic action have been created successfully.' => 'Die automatische Aktion wurde erfolgreich erstellt.',
'Unable to create your automatic action.' => 'Erstellen der automatischen Aktion nicht möglich.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Einem Benutzer eine Farbe zuordnen',
'Column title' => 'Spaltentitel',
'Position' => 'Position',
- 'Move Up' => 'nach oben',
- 'Move Down' => 'nach unten',
'Duplicate to another project' => 'In ein anderes Projekt duplizieren',
'Duplicate' => 'Duplizieren',
'link' => 'Link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Kommentar erfolgreich gelöscht.',
'Unable to remove this comment.' => 'Löschen des Kommentars nicht möglich.',
'Do you really want to remove this comment?' => 'Soll dieser Kommentar wirklich gelöscht werden?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Nur Administratoren und der Ersteller des Kommentars haben Zugriff auf diese Seite.',
'Current password for the user "%s"' => 'Aktuelles Passwort des Benutzers "%s"',
'The current password is required' => 'Das aktuelle Passwort wird benötigt',
'Wrong password' => 'Falsches Passwort',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'Externe Authentifizierung fehlgeschlagen',
'Your external account is linked to your profile successfully.' => 'Dein externer Account wurde erfolgreich mit deinem Profil verbunden',
'Email' => 'E-Mail',
- 'Link my Google Account' => 'Verbinde meinen Google-Account',
- 'Unlink my Google Account' => 'Verbindung mit meinem Google-Account trennen',
- 'Login with my Google Account' => 'Anmelden mit meinem Google-Account',
- 'Project not found.' => 'Das Projekt wurde nicht gefunden.',
'Task removed successfully.' => 'Aufgabe erfolgreich gelöscht.',
'Unable to remove this task.' => 'Löschen der Aufgabe nicht möglich.',
'Remove a task' => 'Aufgabe löschen',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maximalgröße: ',
'Unable to upload the file.' => 'Hochladen der Datei nicht möglich.',
'Display another project' => 'Zu Projekt wechseln',
- 'Login with my Github Account' => 'Anmelden mit meinem Github-Account',
- 'Link my Github Account' => 'Mit meinem Github-Account verbinden',
- 'Unlink my Github Account' => 'Verbindung mit meinem Github-Account trennen',
'Created by %s' => 'Erstellt durch %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Letzte Änderung am %d.%m.%Y um %H:%M',
'Tasks Export' => 'Aufgaben exportieren',
'Tasks exportation for "%s"' => 'Aufgaben exportieren für "%s"',
'Start Date' => 'Anfangsdatum',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Ich möchte nur für diese Projekte Benachrichtigungen erhalten:',
'view the task on Kanboard' => 'diese Aufgabe auf dem Kanboard zeigen',
'Public access' => 'Öffentlicher Zugriff',
- 'User management' => 'Benutzer verwalten',
'Active tasks' => 'Aktive Aufgaben',
'Disable public access' => 'Öffentlichen Zugriff deaktivieren',
'Enable public access' => 'Öffentlichen Zugriff aktivieren',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'E-Mail',
'Notifications:' => 'Benachrichtigungen:',
'Notifications' => 'Benachrichtigungen',
- 'Group:' => 'Gruppe',
- 'Regular user' => 'Standardbenutzer',
'Account type:' => 'Accounttyp:',
'Edit profile' => 'Profil bearbeiten',
'Change password' => 'Passwort ändern',
'Password modification' => 'Passwortänderung',
'External authentications' => 'Externe Authentisierungsmethoden',
- 'Google Account' => 'Google-Account',
- 'Github Account' => 'Github-Account',
'Never connected.' => 'Noch nie verbunden.',
- 'No account linked.' => 'Kein Account verbunden.',
- 'Account linked.' => 'Account verbunden',
'No external authentication enabled.' => 'Es sind keine externen Authentisierungsmethoden aktiv.',
'Password modified successfully.' => 'Passwort wurde erfolgreich geändert.',
'Unable to change the password.' => 'Passwort konnte nicht geändert werden.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s hat die Zuständigkeit der Aufgabe %s geändert um %s',
'New password for the user "%s"' => 'Neues Passwort des Benutzers "%s"',
'Choose an event' => 'Aktion wählen',
- 'Github commit received' => 'Github commit empfangen',
- 'Github issue opened' => 'Github Fehler geöffnet',
- 'Github issue closed' => 'Github Fehler geschlossen',
- 'Github issue reopened' => 'Github Fehler erneut geöffnet',
- 'Github issue assignee change' => 'Github Fehlerzuständigkeit geändert',
- 'Github issue label change' => 'Github Fehlerkennzeichnung verändert',
'Create a task from an external provider' => 'Eine Aufgabe durch einen externen Provider hinzufügen',
'Change the assignee based on an external username' => 'Zuordnung ändern basierend auf externem Benutzernamen',
'Change the category based on an external label' => 'Kategorie basierend auf einer externen Kennzeichnung ändern',
'Reference' => 'Referenz',
- 'Reference: %s' => 'Referenz: %s',
'Label' => 'Kennzeichnung',
'Database' => 'Datenbank',
'About' => 'Über',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frequenz in Sekunden (standardmäßig 60 Sekunden)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frequenz in Sekunden (0 um diese Funktion zu deaktivieren, standardmäßig 10 Sekunden)',
'Application URL' => 'Applikations-URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Beispiel: http://example.kanboard.net/ (wird für E-Mail-Benachrichtigungen verwendet)',
'Token regenerated.' => 'Token wurde neu generiert.',
'Date format' => 'Datumsformat',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO Format wird immer akzeptiert, z.B.: "%s" und "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Dieses Projekt ist privat',
'Type here to create a new sub-task' => 'Hier tippen, um eine neue Teilaufgabe zu erstellen',
'Add' => 'Hinzufügen',
- 'Estimated time: %s hours' => 'Geplante Zeit: %s Stunden',
- 'Time spent: %s hours' => 'Aufgewendete Zeit: %s Stunden',
- 'Started on %B %e, %Y' => 'Gestartet am %B %e %Y',
'Start date' => 'Startdatum',
'Time estimated' => 'Geschätzte Zeit',
'There is nothing assigned to you.' => 'Ihnen ist nichts zugewiesen.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Jeder hat Zugriff zu diesem Projekt',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github-Webhook',
- 'Help on Github webhooks' => 'Hilfe für Github-Webhooks',
'Create a comment from an external provider' => 'Kommentar eines externen Providers hinzufügen',
- 'Github issue comment created' => 'Kommentar zum Github-Issue hinzugefügt',
'Project management' => 'Projektmanagement',
'My projects' => 'Meine Projekte',
'Columns' => 'Spalten',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Benutzerverteilung für "%s"',
'Clone this project' => 'Projekt kopieren',
'Column removed successfully.' => 'Spalte erfolgreich entfernt.',
- 'Github Issue' => 'Github Issue',
'Not enough data to show the graph.' => 'Nicht genügend Daten, um die Grafik zu zeigen.',
'Previous' => 'Vorherige',
'The id must be an integer' => 'Die Id muss eine ganze Zahl sein',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Standard-Swimlane',
'Do you really want to remove this swimlane: "%s"?' => 'Diese Swimlane wirklich ändern: "%s"?',
'Inactive swimlanes' => 'Inaktive Swimlane',
- 'Set project manager' => 'zum Projektmanager machen',
- 'Set project member' => 'zum Projektmitglied machen',
'Remove a swimlane' => 'Swimlane entfernen',
- 'Rename' => 'umbenennen',
'Show default swimlane' => 'Standard-Swimlane anzeigen',
'Swimlane modification for the project "%s"' => 'Swimlane-Änderung für das Projekt "%s"',
'Swimlane not found.' => 'Swimlane nicht gefunden',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane erfolgreich geändert.',
'The default swimlane have been updated successfully.' => 'Die Standard-Swimlane wurden erfolgreich aktualisiert. Die Standard-Swimlane wurden erfolgreich aktualisiert.',
- 'Unable to create your swimlane.' => 'Es ist nicht möglich, Swimlane zu erstellen.',
'Unable to remove this swimlane.' => 'Es ist nicht möglich, die Swimlane zu entfernen.',
'Unable to update this swimlane.' => 'Es ist nicht möglich, die Swimlane zu ändern.',
'Your swimlane have been created successfully.' => 'Die Swimlane wurde erfolgreich angelegt.',
'Example: "Bug, Feature Request, Improvement"' => 'Beispiel: "Bug, Funktionswünsche, Verbesserung"',
'Default categories for new projects (Comma-separated)' => 'Standard-Kategorien für neue Projekte (Komma-getrennt)',
- 'Gitlab commit received' => 'Gitlab-Commit erhalten',
- 'Gitlab issue opened' => 'Gitlab-Issue eröffnet',
- 'Gitlab issue closed' => 'Gitlab-Issue geschlossen',
- 'Gitlab webhooks' => 'Gitlab-Webhook',
- 'Help on Gitlab webhooks' => 'Hilfe für Gitlab-Webhooks',
'Integrations' => 'Integration',
'Integration with third-party services' => 'Integration von externen Diensten',
- 'Role for this project' => 'Rolle für dieses Projekt',
- 'Project manager' => 'Projektmanager',
- 'Project member' => 'Projektmitglied',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Ein Projektmanager kann die Projekteinstellungen ändern und hat mehr Rechte als ein normaler Benutzer.',
- 'Gitlab Issue' => 'Gitlab-Issue',
'Subtask Id' => 'Teilaufgaben-ID',
'Subtasks' => 'Teilaufgaben',
'Subtasks Export' => 'Export von Teilaufgaben',
@@ -588,7 +523,7 @@ return array(
'All columns' => 'Alle Spalten',
'Calendar' => 'Kalender',
'Next' => 'Nächste',
- // '#%d' => '',
+ '#%d' => 'Nr %d',
'All swimlanes' => 'Alle Swimlanes',
'All colors' => 'Alle Farben',
'Moved to column %s' => 'In Spalte %s verschoben',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Bereits eine Teilaufgabe in Bearbeitung',
'Which parts of the project do you want to duplicate?' => 'Welcher Teil des Projekts soll kopiert werden?',
'Disallow login form' => 'Verbiete Login-Formular',
- 'Bitbucket commit received' => 'Bitbucket-Commit erhalten',
- 'Bitbucket webhooks' => 'Bitbucket-Webhooks',
- 'Help on Bitbucket webhooks' => 'Hilfe für Bitbucket-Webhooks',
'Start' => 'Start',
'End' => 'Ende',
'Task age in days' => 'Aufgabenalter in Tagen',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Diese Aufgabe',
'<1h' => '<1Std',
'%dh' => '%dStd',
- // '%b %e' => '',
'Expand tasks' => 'Aufgaben aufklappen',
'Collapse tasks' => 'Aufgaben zusammenklappen',
'Expand/collapse tasks' => 'Aufgaben auf/zuklappen',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Tastaturkürzel',
'Open board switcher' => 'Pinnwandauswahl öffnen',
'Application' => 'Anwendung',
- 'since %B %e, %Y at %k:%M %p' => 'seit %B %e, %Y um %k:%M %p',
'Compact view' => 'Kompaktansicht',
'Horizontal scrolling' => 'Horizontales Scrollen',
'Compact/wide view' => 'Kompakt/Breite-Ansicht',
'No results match:' => 'Keine Ergebnisse:',
'Currency' => 'Währung',
- 'Files' => 'Dateien',
- 'Images' => 'Bilder',
'Private project' => 'privates Projekt',
'AUD - Australian Dollar' => 'AUD - Australische Dollar',
'CAD - Canadian Dollar' => 'CAD - Kanadische Dollar',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'Der Zwei-Faktor-Authentifizierungscode ist gültig.',
'Code' => 'Code',
'Two factor authentication' => 'Zwei-Faktor-Authentifizierung',
- 'Enable/disable two factor authentication' => 'Aktiviere/Deaktiviere Zwei-Faktor-Authentifizierung',
'This QR code contains the key URI: ' => 'Dieser QR-Code beinhaltet die Schlüssel-URI',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Speichere den geheimen Schlüssel in deiner TOTP software (z.B. Google Authenticator oder FreeOTP).',
'Check my code' => 'Überprüfe meinen Code',
'Secret key: ' => 'Geheimer Schlüssel',
'Test your device' => 'Teste dein Gerät',
'Assign a color when the task is moved to a specific column' => 'Weise eine Farbe zu, wenn die Aufgabe zu einer bestimmten Spalte bewegt wird',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'Hochgeladen von: %s',
- 'uploaded on: %s' => 'Hochgeladen am: %s',
- 'size: %s' => 'Größe: %s',
'Burndown chart for "%s"' => 'Burndown-Diagramm für "%s"',
'Burndown chart' => 'Burndown-Diagramm',
'This chart show the task complexity over the time (Work Remaining).' => 'Dieses Diagramm zeigt die Aufgabenkomplexität über den Faktor Zeit (Verbleibende Arbeit).',
@@ -722,7 +645,6 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Nimm einen Screenshot auf und drücke STRG+V oder ⌘+V um ihn hier einzufügen.',
'Screenshot uploaded successfully.' => 'Screenshot erfolgreich hochgeladen.',
'SEK - Swedish Krona' => 'SEK - Schwedische Kronen',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Der Projektidentifikator ist ein optionaler alphanumerischer Code, der das Projekt identifiziert.',
'Identifier' => 'Identifikator',
'Disable two factor authentication' => 'Deaktiviere Zwei-Faktor-Authentifizierung',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Willst du wirklich für folgenden Nutzer die Zwei-Faktor-Authentifizierung deaktivieren: "%s"?',
@@ -731,7 +653,6 @@ return array(
'A task cannot be linked to itself' => 'Eine Aufgabe kann nicht mit sich selber verbunden werden',
'The exact same link already exists' => 'Diese Verbindung existiert bereits',
'Recurrent task is scheduled to be generated' => 'Wiederkehrende Aufgabe ist zur Generierung eingeplant',
- 'Recurring information' => 'Wiederkehrende Information',
'Score' => 'Wertung',
'The identifier must be unique' => 'Der Schlüssel muss einzigartig sein',
'This linked task id doesn\'t exists' => 'Die verbundene Aufgabe existiert nicht',
@@ -777,21 +698,10 @@ return array(
'User that will receive the email' => 'Empfänger der E-Mail',
'Email subject' => 'E-Mail-Betreff',
'Date' => 'Datum',
- 'By @%s on Bitbucket' => 'Durch @%s auf Bitbucket',
- 'Bitbucket Issue' => 'Bitbucket-Issue',
- 'Commit made by @%s on Bitbucket' => 'Commit von @%s auf Bitbucket',
- 'Commit made by @%s on Github' => 'Commit von @%s auf Github',
- 'By @%s on Github' => 'Durch @%s auf Github',
- 'Commit made by @%s on Gitlab' => 'Commit von @%s auf Gitlab',
'Add a comment log when moving the task between columns' => 'Kommentar hinzufügen, wenn Aufgabe in andere Spalte verschoben wird',
'Move the task to another column when the category is changed' => 'Aufgabe in andere Spalte verschieben, wenn Kategorie geändert wird',
'Send a task by email to someone' => 'Aufgabe per E-Mail versenden',
'Reopen a task' => 'Aufgabe wieder öffnen',
- 'Bitbucket issue opened' => 'Bitbucket Ticket eröffnet',
- 'Bitbucket issue closed' => 'Bitbucket Ticket geschlossen',
- 'Bitbucket issue reopened' => 'Bitbucket Ticket wieder eröffnet',
- 'Bitbucket issue assignee change' => 'Bitbucket Ticket Zuordnung geändert',
- 'Bitbucket issue comment created' => 'Bitbucket Ticket Kommentar erstellt',
'Column change' => 'Spalte geändert',
'Position change' => 'Position geändert',
'Swimlane change' => 'Swimlane geändert',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'Das Feld "%s" wurde verändert',
'The description have been modified' => 'Die Beschreibung wurde geändert',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Soll die Aufgabe "%s" wirklich geschlossen werden? (einschließlich Teilaufgaben)',
- 'Swimlane: %s' => 'Swimlane: %s',
'I want to receive notifications for:' => 'Ich möchte Benachrichtigungen erhalten für:',
'All tasks' => 'Alle Aufgaben',
'Only for tasks assigned to me' => 'nur mir zugeordnete Aufgane',
'Only for tasks created by me' => 'nur von mir erstellte Aufgaben',
'Only for tasks created by me and assigned to me' => 'nur mir zugeordnete und von mir erstellte Aufgaben',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%b %e, %Y, %k:%M %p',
- 'New due date: %B %e, %Y' => 'Neues Ablaufdatum: %B %e, %Y',
- 'Start date changed: %B %e, %Y' => 'Neues Beginndatum: %B %e, %Y',
- '%k:%M %p' => '%k:%M %p',
'%%Y-%%m-%%d' => '%%d.%%m.%%Y',
'Total for all columns' => 'Gesamt für alle Spalten',
'You need at least 2 days of data to show the chart.' => 'Es werden mindestens 2 Tage zur Darstellung benötigt',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Nicht zugewiesen',
'View advanced search syntax' => 'Zur erweiterten Suchsyntax',
'Overview' => 'Überblick',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Board-/Kalender-/Listen-Ansicht',
'Switch to the board view' => 'Zur Board-Ansicht',
'Switch to the calendar view' => 'Zur Kalender-Ansicht',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Das Diagramm zeigt die durchschnittliche Durchlauf- und Zykluszeit der letzten %d Aufgaben über die Zeit an.',
'Average time into each column' => 'Durchschnittzeit in jeder Spalte',
'Lead and cycle time' => 'Durchlauf- und Zykluszeit',
- 'Google Authentication' => 'Google-Authentifizierung',
- 'Help on Google authentication' => 'Hilfe bei Google-Authentifizierung',
- 'Github Authentication' => 'Github-Authentifizierung',
- 'Help on Github authentication' => 'Hilfe bei Github-Authentifizierung',
'Lead time: ' => 'Durchlaufzeit:',
'Cycle time: ' => 'Zykluszeit:',
'Time spent into each column' => 'zeit verbracht in jeder Spalte',
@@ -906,18 +805,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Wenn die Aufgabe nicht geschlossen ist, wird die aktuelle Zeit statt der Fertigstellung verwendet.',
'Set automatically the start date' => 'Setze Startdatum automatisch',
'Edit Authentication' => 'Authentifizierung bearbeiten',
- 'Google Id' => 'Google Id',
- 'Github Id' => 'Github Id',
'Remote user' => 'Remote-Benutzer',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Remote-Benutzer haben kein Passwort in der Kanboard Datenbank, Beispiel LDAP, Goole und Github Accounts',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Wenn die Box "Verbiete Login-Formular" angeschaltet ist, werden Eingaben in das Login Formular ignoriert.',
- 'By @%s on Gitlab' => 'Durch @%s auf Gitlab',
- 'Gitlab issue comment created' => 'Gitlab Ticket Kommentar erstellt',
'New remote user' => 'Neuer Remote-Benutzer',
'New local user' => 'Neuer lokaler Benutzer',
'Default task color' => 'Voreingestellte Aufgabenfarbe',
- 'Hide sidebar' => 'Seitenleiste verstecken',
- 'Expand sidebar' => 'Seitenleiste ausklappen',
'This feature does not work with all browsers.' => 'Diese Funktion funktioniert nicht mit allen Browsern',
'There is no destination project available.' => 'Es ist kein Zielprojekt vorhanden.',
'Trigger automatically subtask time tracking' => 'Teilaufgaben Zeiterfassung automatisch starten',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'Mitwirkende',
'License:' => 'Lizenz:',
'License' => 'Lizenz',
- 'Project Administrator' => 'Projektadministrator',
'Enter the text below' => 'Text unten eingeben',
'Gantt chart for %s' => 'Gantt Diagramm für %s',
'Sort by position' => 'Nach Position sortieren',
@@ -952,11 +844,9 @@ return array(
'open file' => 'Datei öffnen',
'End date' => 'Endedatum',
'Users overview' => 'Benutzerübersicht',
- 'Managers' => 'Manager',
'Members' => 'Mitglieder',
'Shared project' => 'Geteiltes Projekt',
'Project managers' => 'Projektmanager',
- 'Project members' => 'Projektmitglieder',
'Gantt chart for all projects' => 'Gantt Diagramm für alle Projekte',
'Projects list' => 'Projektliste',
'Gantt chart for this project' => 'Gantt Diagramm für dieses Projekt',
@@ -964,26 +854,16 @@ return array(
'End date:' => 'Endedatum:',
'There is no start date or end date for this project.' => 'Es gibt kein Startdatum oder Endedatum für dieses Projekt',
'Projects Gantt chart' => 'Projekt Gantt Diagramm',
- 'Start date: %s' => 'Beginndatum: %s',
- 'End date: %s' => 'Enddatum: %s',
'Link type' => 'Verbindungstyp',
'Change task color when using a specific task link' => 'Aufgabefarbe ändern bei bestimmter Aufgabenverbindung',
'Task link creation or modification' => 'Aufgabenverbindung erstellen oder bearbeiten',
- 'Login with my Gitlab Account' => 'Mit Gitlab Account einloggen',
'Milestone' => 'Meilenstein',
- 'Gitlab Authentication' => 'Gitlab-Authentifizierung',
- 'Help on Gitlab authentication' => 'Hilfe bei Gitlab-Authentifizierung',
- 'Gitlab Id' => 'Gitlab Id',
- 'Gitlab Account' => 'Gitlab Account',
- 'Link my Gitlab Account' => 'Verknüpfe mein Gitlab Account',
- 'Unlink my Gitlab Account' => 'Trenne meinen Gitlab Account',
'Documentation: %s' => 'Dokumentation: %s',
'Switch to the Gantt chart view' => 'Zur Gantt-Diagramm Ansicht wechseln',
'Reset the search/filter box' => 'Suche/Filter-Box zurücksetzen',
'Documentation' => 'Dokumentation',
'Table of contents' => 'Inhaltsverzeichnis',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Hilfe bei Projektberechtigungen',
'Author' => 'Autor',
'Version' => 'Version',
'Plugins' => 'Plugins',
@@ -1028,40 +908,244 @@ return array(
'Unread notifications' => 'Ungelesene Benachrichtigungen',
'My filters' => 'Meine Filter',
'Notification methods:' => 'Benachrichtigungs-Methoden:',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'Import tasks from CSV file' => 'Importiere Aufgaben aus CSV Datei',
+ 'Unable to read your file' => 'Die Datei kann nicht gelesen werden',
+ '%d task(s) have been imported successfully.' => '%d Aufgabe(n) wurde(n) erfolgreich importiert',
+ 'Nothing have been imported!' => 'Es wurde nichts importiert!',
+ 'Import users from CSV file' => 'Importiere Benutzer aus CSV Datei',
+ '%d user(s) have been imported successfully.' => '%d Benutzer wurde(n) erfolgreich importiert.',
+ 'Comma' => 'Komma',
+ 'Semi-colon' => 'Semikolon',
+ 'Tab' => 'Tabulator',
+ 'Vertical bar' => 'senkrechter Strich',
+ 'Double Quote' => 'Doppelte Anführungszeichen',
+ 'Single Quote' => 'Einfache Anführungszeichen',
+ '%s attached a file to the task #%d' => '%s hat eine Datei zur Aufgabe #%d hinzugefügt',
+ 'There is no column or swimlane activated in your project!' => 'Es ist keine Spalte oder Swimlane in Ihrem Projekt aktiviert!',
+ 'Append filter (instead of replacement)' => 'Filter anhängen (statt zu ersetzen)',
+ 'Append/Replace' => 'Anhängen/Ersetzen',
+ 'Append' => 'Anhängen',
+ 'Replace' => 'Ersetzen',
+ 'Import' => 'Import',
+ 'change sorting' => 'Sortierung ändern',
+ 'Tasks Importation' => 'Aufgaben Import',
+ 'Delimiter' => 'Trennzeichen',
+ 'Enclosure' => 'Anlage',
+ 'CSV File' => 'CSV Datei',
+ 'Instructions' => 'Anweisungen',
+ 'Your file must use the predefined CSV format' => 'Ihre Datei muss das vorgegebene CSV Format haben',
+ 'Your file must be encoded in UTF-8' => 'Ihre Datei muss UTF-8 kodiert sein',
+ 'The first row must be the header' => 'Die erste Zeile muss die Kopfzeile sein',
+ 'Duplicates are not verified for you' => 'Duplikate werden nicht für Sie geprüft',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'Das Fälligkeitsdatum muss das ISO Format haben: YYYY-MM-DD',
+ 'Download CSV template' => 'CSV Vorlage herunterladen',
+ 'No external integration registered.' => 'Keine externe Integration registriert',
+ 'Duplicates are not imported' => 'Duplikate wurden nicht importiert',
+ 'Usernames must be lowercase and unique' => 'Benutzernamen müssen in Kleinbuschstaben und eindeutig sein',
+ 'Passwords will be encrypted if present' => 'Passwörter werden verschlüsselt wenn vorhanden',
+ '%s attached a new file to the task %s' => '%s hat eine neue Datei zur Aufgabe %s hinzufgefügt',
+ 'Assign automatically a category based on a link' => 'Linkbasiert eine Kategorie automatisch zuordnen',
+ 'BAM - Konvertible Mark' => 'BAM - Konvertible Mark',
+ 'Assignee Username' => 'Benutzername des Zuständigen',
+ 'Assignee Name' => 'Name des Zuständigen',
+ 'Groups' => 'Gruppen',
+ 'Members of %s' => 'Mitglied von %s',
+ 'New group' => 'Neue Gruppe',
+ 'Group created successfully.' => 'Gruppe erfolgreich angelegt.',
+ 'Unable to create your group.' => 'Gruppe konnte nicht angelegt werden',
+ 'Edit group' => 'Gruppe bearbeiten',
+ 'Group updated successfully.' => 'Gruppe erfolgreich aktualisiert',
+ 'Unable to update your group.' => 'Gruppe konnte nicht aktualisiert werden',
+ 'Add group member to "%s"' => 'Gruppenmitglied zu "%s" hinzufügen',
+ 'Group member added successfully.' => 'Gruppenmitglied erfolgreich hinzugefügt',
+ 'Unable to add group member.' => 'Gruppenmitglied konnte nicht hinzugefügt werden.',
+ 'Remove user from group "%s"' => 'Benutzer aus Gruppe "%s" löschen',
+ 'User removed successfully from this group.' => 'Benutzer erfolgreich aus dieser Gruppe gelöscht.',
+ 'Unable to remove this user from the group.' => 'Benutzer konnte nicht aus dieser Gruppe gelöscht werden.',
+ 'Remove group' => 'Gruppe löschen',
+ 'Group removed successfully.' => 'Gruppe erfolgreich gelöscht.',
+ 'Unable to remove this group.' => 'Gruppe konnte nicht gelöscht werden.',
+ 'Project Permissions' => 'Projekt Berechtigungen',
+ 'Manager' => 'Manager',
+ 'Project Manager' => 'Projekt Manager',
+ 'Project Member' => 'Projekt Mitglied',
+ 'Project Viewer' => 'Projekt Betrachter',
+ 'Your account is locked for %d minutes' => 'Ihr Zugang wurde für %d Minuten gesperrt',
+ 'Invalid captcha' => 'Ungültiges Captcha',
+ 'The name must be unique' => 'Der Name muss eindeutig sein',
+ 'View all groups' => 'Alle Gruppen anzeigen',
+ 'View group members' => 'Gruppenmitglieder anzeigen',
+ 'There is no user available.' => 'Es ist kein Benutzer verfügbar.',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Wollen Sie den Benutzer "%s" wirklich aus der Gruppe "%s" löschen?',
+ 'There is no group.' => 'Es gibt keine Gruppe.',
+ 'External Id' => 'Externe ID',
+ 'Add group member' => 'Gruppenmitglied hinzufügen',
+ 'Do you really want to remove this group: "%s"?' => 'Wollen Sie die Gruppe "%s" wirklich löschen?',
+ 'There is no user in this group.' => 'Es gibt keinen Benutzer in dieser Gruppe.',
+ 'Remove this user' => 'Diesen Benutzer löschen',
+ 'Permissions' => 'Berechtigungen',
+ 'Allowed Users' => 'Berechtigte Benutzer',
+ 'No user have been allowed specifically.' => 'Keine Benutzer mit ausdrücklicher Berechtigung.',
+ 'Role' => 'Rolle',
+ 'Enter user name...' => 'Geben Sie den Benutzernamem ein...',
+ 'Allowed Groups' => 'Berechtigte Gruppen',
+ 'No group have been allowed specifically.' => 'Keine Gruppen mit ausdrücklicher Berechtigung.',
+ 'Group' => 'Gruppe',
+ 'Group Name' => 'Gruppenname',
+ 'Enter group name...' => 'Geben Sie den Gruppennamen ein...',
+ 'Role:' => 'Rolle:',
+ 'Project members' => 'Projektmitglieder',
+ 'Compare hours for "%s"' => 'Vergleich der Stunden für %s',
+ '%s mentioned you in the task #%d' => '%s erwähnte Sie in Aufgabe #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s erwähnte Sie in einem Kommentar zur Aufgabe #%d',
+ 'You were mentioned in the task #%d' => 'Sie wurden in der Aufgabe #%d erwähnt',
+ 'You were mentioned in a comment on the task #%d' => 'Sie wurden in einem Kommentar zur Aufgabe #%d erwähnt',
+ 'Mentioned' => 'Erwähnt',
+ 'Compare Estimated Time vs Actual Time' => 'Vergleich zwischen erwartetem und tatsächlichem Zeitaufwand',
+ 'Estimated hours: ' => 'Erwarteter Zeitaufwand (Stunden): ',
+ 'Actual hours: ' => 'Tatsächlich aufgewändete Stunden: ',
+ 'Hours Spent' => 'Stunden aufgewändet',
+ 'Hours Estimated' => 'Stunden erwartet',
+ 'Estimated Time' => 'Erwartete Zeit',
+ 'Actual Time' => 'Aktuelle Zeit',
+ 'Estimated vs actual time' => 'Erwarteter vs. tatsächlicher Zeitaufwand',
+ 'RUB - Russian Ruble' => 'Russischer Rubel',
+ 'Assign the task to the person who does the action when the column is changed' => 'Aufgabe der Person zuordnen, die die Aktion durchführt, wenn die Spalte geändert wird',
+ 'Close a task in a specific column' => 'Schliesse eine Aufgabe in einer bestimmten Spalte',
+ 'Time-based One-time Password Algorithm' => 'Zeitbasierter Einmalpasswort Algorithmus',
+ 'Two-Factor Provider: ' => '2FA Anbieter: ',
+ 'Disable two-factor authentication' => 'Zwei-Faktor-Authentifizierung deaktivieren',
+ 'Enable two-factor authentication' => 'Zwei-Faktor-Authentifizierung aktivieren',
+ 'There is no integration registered at the moment.' => 'Derzeit ist kein externer Dienst registriert.',
+ 'Password Reset for Kanboard' => 'Zurücksetzen des Passwortes für Kanboard',
+ 'Forgot password?' => 'Passwort vergessen?',
+ 'Enable "Forget Password"' => 'Passwortrücksetzung aktivieren',
+ 'Password Reset' => 'Passwort zurücksetzen',
+ 'New password' => 'Neues Passwort',
+ 'Change Password' => 'Passwort ändern',
+ 'To reset your password click on this link:' => 'Bitte auf den Link klicken, um Ihr Passwort zurückzusetzen.',
+ 'Last Password Reset' => 'Verlauf der Passwortrücksetzung',
+ 'The password has never been reinitialized.' => 'Das Passwort wurde noch nie zurückgesetzt.',
+ 'Creation' => 'Erstellung',
+ 'Expiration' => 'Ablauf',
+ 'Password reset history' => 'Verlauf Passwortrücksetzung',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Alle Aufgaben der Spalte "%s" und der Swimlane "%s" wurden erfolgreich geschlossen',
+ 'Do you really want to close all tasks of this column?' => 'Wollen Sie wirklich alle Aufgaben in dieser Spalte schließen?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d Aufgabe(n) in der Spalte "%s" und in der Swimlane "%s" werden geschlossen.',
+ 'Close all tasks of this column' => 'Alle Aufgaben in dieser Spalte schließen',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Kein Plugin hat eine Projekt-Benachrichtigungsmethode registriert. Sie können individuelle Meldungen in Ihrem Benutzerprofil konfigurieren',
+ 'My dashboard' => 'Mein Dashboard',
+ 'My profile' => 'Mein Profil',
+ 'Project owner: ' => 'Projekt-Besitzer:',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'Die Projekt-Kennung ist optional und muss alphanumerisch sein, beispielsweise: MYPROJECT.',
+ 'Project owner' => 'Projekt-Besitzer',
+ 'Those dates are useful for the project Gantt chart.' => 'Diese Daten sind nützlich für das Gantt-Diagramm.',
+ 'Private projects do not have users and groups management.' => 'Private Projekte haben kein Benutzer- und Gruppen-Management.',
+ 'There is no project member.' => 'Es gibt kein Projekt-Mitglied.',
+ 'Priority' => 'Priorität',
+ 'Task priority' => 'Aufgaben-Priorität',
+ 'General' => 'Allgemein',
+ 'Dates' => 'Daten',
+ 'Default priority' => 'Standard-Priorität',
+ 'Lowest priority' => 'Niedrigste Priorität',
+ 'Highest priority' => 'Höchste Priorität',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'Wenn Sie Null bei höchster und niedrigster Priorität eintragen, wird diese Funktion deaktiviert.',
+ 'Close a task when there is no activity' => 'Schliesse eine Aufgabe, wenn keine Aktivitäten vorhanden sind',
+ 'Duration in days' => 'Dauer in Tagen',
+ 'Send email when there is no activity on a task' => 'Versende eine Email, wenn keine Aktivitäten an einer Aufgabe vorhanden sind',
+ 'List of external links' => 'Liste der externen Verbindungen',
+ 'Unable to fetch link information.' => 'Kann keine Informationen über Verbindungen holen',
+ 'Daily background job for tasks' => 'Tägliche Hintergrundarbeit für Aufgaben',
+ 'Auto' => 'Auto',
+ 'Related' => 'Verbunden',
+ 'Attachment' => 'Anhang',
+ 'Title not found' => 'Titel nicht gefunden',
+ 'Web Link' => 'Weblink',
+ 'External links' => 'Externe Verbindungen',
+ 'Add external link' => 'Externe Verbindung hinzufügen',
+ 'Type' => 'Typ',
+ 'Dependency' => 'Abhängigkeit',
+ 'Add internal link' => 'Füge interne Verbindung hinzu',
+ 'Add a new external link' => 'Füge eine neue externe Verbindung hinzu',
+ 'Edit external link' => 'Externe Verbindung bearbeiten',
+ 'External link' => 'Externe Verbindung',
+ 'Copy and paste your link here...' => 'Kopieren Sie Ihren Link hier...',
+ 'URL' => 'URL',
+ 'There is no external link for the moment.' => 'Es gibt im Moment keine externe Verbindung.',
+ 'Internal links' => 'Interne Verbindungen',
+ 'There is no internal link for the moment.' => 'Es gibt im Moment keine interne Verbindung.',
+ 'Assign to me' => 'Mir zuweisen',
+ 'Me' => 'Mich',
+ 'Do not duplicate anything' => 'Nichts duplizieren',
+ 'Projects management' => 'Projektmanagement',
+ 'Users management' => 'Benutzermanagement',
+ 'Groups management' => 'Gruppenmanagement',
+ 'Create from another project' => 'Von einem anderen Projekt erstellen',
+ 'There is no subtask at the moment.' => 'Es gibt im Moment keine Teilaufgabe',
+ 'open' => 'offen',
+ 'closed' => 'geschlossen',
+ 'Priority:' => 'Priorität:',
+ 'Reference:' => 'Bezug:',
+ 'Complexity:' => 'Komplexität:',
+ 'Swimlane:' => 'Swimlane:',
+ 'Column:' => 'Spalte:',
+ 'Position:' => 'Position:',
+ 'Creator:' => 'Ersteller:',
+ 'Time estimated:' => 'Geschätzte Zeit:',
+ '%s hours' => '%s Stunden',
+ 'Time spent:' => 'Aufgewendete Zeit:',
+ 'Created:' => 'Erstellt:',
+ 'Modified:' => 'Geändert:',
+ 'Completed:' => 'Abgeschlossen:',
+ 'Started:' => 'Gestarted:',
+ 'Moved:' => 'Verschoben:',
+ 'Task #%d' => 'Aufgabe #%d',
+ 'Sub-tasks' => 'Teilaufgaben',
+ 'Date and time format' => 'Datums- und Zeitformat',
+ 'Time format' => 'Zeitformat',
+ 'Start date: ' => 'Anfangsdatum:',
+ 'End date: ' => 'Enddatum:',
+ 'New due date: ' => 'Neues Fälligkeitsdatum',
+ 'Start date changed: ' => 'Anfangsdatum geändert:',
+ 'Disable private projects' => 'Private Projekte deaktivieren',
+ 'Do you really want to remove this custom filter: "%s"?' => 'Wollen Sie diesen benutzerdefinierten Filter wirklich entfernen: "%s"?',
+ 'Remove a custom filter' => 'Benutzerdefinierten Filter entfernen',
+ 'User activated successfully.' => 'Benutzer erfolgreich aktiviert.',
+ 'Unable to enable this user.' => 'Dieser Benutzer kann nicht aktiviert werden.',
+ 'User disabled successfully.' => 'Benutzer erfolgreich deaktiviert.',
+ 'Unable to disable this user.' => 'Dieser Benutzer kann nicht deaktiviert werden.',
+ 'All files have been uploaded successfully.' => 'Alle Dateien wurden erfolgreich hochgeladen.',
+ 'View uploaded files' => 'Hochgeladene Dateien ansehen',
+ 'The maximum allowed file size is %sB.' => 'Die maximal erlaubte Dateigröße ist %sB.',
+ 'Choose files again' => 'Wählen Sie erneut Dateien aus',
+ 'Drag and drop your files here' => 'Ziehen Sie Ihre Dateien hier hin',
+ 'choose files' => 'Dateien auswählen',
+ 'View profile' => 'Profil ansehen',
+ 'Two Factor' => 'Zwei-Faktor',
+ 'Disable user' => 'Benutzer deaktivieren',
+ 'Do you really want to disable this user: "%s"?' => 'Wollen Sie diesen Benutzer wirklich deaktivieren: "%s"?',
+ 'Enable user' => 'Benutzer aktivieren',
+ 'Do you really want to enable this user: "%s"?' => 'Wollen Sie diesen Benutzer wirklich aktivieren: "%s"?',
+ 'Download' => 'Runterladen',
+ 'Uploaded: %s' => 'Heraufgeladen: %s',
+ 'Size: %s' => 'Größe: %s',
+ 'Uploaded by %s' => 'Heraufgeladen von %s',
+ 'Filename' => 'Dateiname',
+ 'Size' => 'Größe',
+ 'Column created successfully.' => 'Spalte erfolgreich erstellt.',
+ 'Another column with the same name exists in the project' => 'Es gibt bereits eine Spalte mit demselben Namen im Projekt',
+ 'Default filters' => 'Standard-Filter',
+ 'Your board doesn\'t have any column!' => 'Es gibt keine Spalten in diesem Projekt!',
+ 'Change column position' => 'Position der Spalte ändern',
+ 'Switch to the project overview' => 'Zur Projektübersicht wechseln',
+ 'User filters' => 'Benutzer-Filter',
+ 'Category filters' => 'Kategorie-Filter',
+ 'Upload a file' => 'Eine Datei hochladen',
+ 'There is no attachment at the moment.' => 'Es gibt zur Zeit keine Anhänge',
+ 'View file' => 'Datei ansehen',
+ 'Last activity' => 'Letzte Aktivität',
+ 'Change subtask position' => 'Position der Unteraufgabe ändern',
+ 'This value must be greater than %d' => 'Dieser Wert muss größer als %d sein',
+ 'Another swimlane with the same name exists in the project' => 'Es gibt bereits eine Swimlane mit diesem Namen im Projekt',
+ 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => 'Beispiel: http://example.kanboard.net (wird zum Erstellen absoluter URLs genutzt)',
);
diff --git a/app/Locale/el_GR/translations.php b/app/Locale/el_GR/translations.php
new file mode 100644
index 00000000..e59949cf
--- /dev/null
+++ b/app/Locale/el_GR/translations.php
@@ -0,0 +1,1151 @@
+<?php
+
+return array(
+ 'number.decimals_separator' => ',',
+ 'number.thousands_separator' => '.',
+ 'None' => 'Τίποτα',
+ 'edit' => 'Διορθωτής',
+ 'Edit' => 'Διόρθωση',
+ 'remove' => 'αφαιρετής',
+ 'Remove' => 'Αφαίρεση',
+ 'Update' => 'Ενημέρωση',
+ 'Yes' => 'Ναι',
+ 'No' => 'Όχι',
+ 'cancel' => 'ακύρωση',
+ 'or' => 'ή',
+ 'Yellow' => 'Κίτρινο',
+ 'Blue' => 'Μπλέ',
+ 'Green' => 'Πράσινο',
+ 'Purple' => 'Βιολετί',
+ 'Red' => 'Κόκκινο',
+ 'Orange' => 'Ποστοκαλί',
+ 'Grey' => 'Γκρίζο',
+ 'Brown' => 'Καφέ',
+ 'Deep Orange' => 'Βαθύ πορτοκαλί',
+ 'Dark Grey' => 'Βαθύ γκρί',
+ 'Pink' => 'Ρόζ',
+ 'Teal' => 'Τουρκουάζ',
+ 'Cyan' => 'Γαλάζιο',
+ 'Lime' => 'Λεμονί',
+ 'Light Green' => 'Ανοιχτό πράσινο',
+ 'Amber' => 'Κεχριμπαρί',
+ 'Save' => 'Αποθήκευση',
+ 'Login' => 'Είσοδος',
+ 'Official website:' => 'Επίσημο web site :',
+ 'Unassigned' => 'Μη εκχωρημένο',
+ 'View this task' => 'Προβολή της εργασίας',
+ 'Remove user' => 'Αφαίρεση χρήστη',
+ 'Do you really want to remove this user: "%s"?' => 'Θέλετε σίγουρα να αφαιρέσετε αυτό τον χρήστη : « %s » ?',
+ 'New user' => 'Νέος Χρήστης',
+ 'All users' => 'Όλοι οι χρήστες',
+ 'Username' => 'Όνομα χρήστη',
+ 'Password' => 'Κωδικός',
+ 'Administrator' => 'Διαχειριστής',
+ 'Sign in' => 'Είσοδος',
+ 'Users' => 'Χρήστες',
+ 'No user' => 'Δεν υπάρχει χρήστης',
+ 'Forbidden' => 'Δεν επιτρέπεται η πρόσβαση',
+ 'Access Forbidden' => 'Δεν επιτρέπεται η πρόσβαση',
+ 'Edit user' => 'Διόρθωση χρήστη',
+ 'Logout' => 'Αποσύνδεση',
+ 'Bad username or password' => 'Λάθος όνομα χρήστη ή κωδικού πρόσβασης',
+ 'Edit project' => 'Διόρθωση έργου',
+ 'Name' => 'Όνομα',
+ 'Projects' => 'Έργα',
+ 'No project' => 'Δεν υπάρχουν μέλη για το έργο',
+ 'Project' => 'Έργο',
+ 'Status' => 'Κατάσταση',
+ 'Tasks' => 'Εργασίες',
+ 'Board' => 'Κεντρικός πίνακας έργου',
+ 'Actions' => 'Ενέργειες',
+ 'Inactive' => 'Ανενεργός',
+ 'Active' => 'Ενεργός',
+ 'Add this column' => 'Προσθήκη αυτής της στήλης',
+ '%d tasks on the board' => '%d εργασίες στον κεντρικό πίνακα έργου',
+ '%d tasks in total' => '%d εργασιών στο σύνολο',
+ 'Unable to update this board.' => 'Αδύνατη η ενημέρωση αυτού του πίνακα',
+ 'Edit board' => 'Ενημέρωση πίνακα',
+ 'Disable' => 'Απενεργοποίηση',
+ 'Enable' => 'Ενεργοποίηση',
+ 'New project' => 'Νέο έργο',
+ 'Do you really want to remove this project: "%s"?' => 'Αφαίρεση του έργου : « %s » ?',
+ 'Remove project' => 'Αφαίρεση του έργου',
+ 'Edit the board for "%s"' => 'Διόρθωση πίνακα από « %s »',
+ 'All projects' => 'Όλα τα έργα',
+ 'Add a new column' => 'Πρόσθήκη στήλης',
+ 'Title' => 'Τίτλος',
+ 'Nobody assigned' => 'Δεν έχει ανατεθεί',
+ 'Assigned to %s' => 'Ανατιθεμένο στον %s',
+ 'Remove a column' => 'Αφαίρεση στήλης',
+ 'Remove a column from a board' => 'Αφαίρεση στήλης από τον πίνακα',
+ 'Unable to remove this column.' => 'Αδύνατη η αφαίρεση της στήλης',
+ 'Do you really want to remove this column: "%s"?' => 'Θέλετε να αφαιρέσετε τη στήλη : « %s » ?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'Αυτή η ενέργεια θα ΑΦΑΙΡΕΣΕΙ ΟΛΕΣ ΤΙΣ ΕΡΓΑΣΙΕΣ που είναι σχετικές με τη στήλη!!',
+ 'Settings' => 'Προτιμήσεις',
+ 'Application settings' => 'Παραμετροποίηση εφαρμογής',
+ 'Language' => 'Γλώσσα',
+ 'Webhook token:' => 'Διακριτικό ασφαλείας (token) webhooks :',
+ 'API token:' => 'Διακριτικό ασφαλείας (token) API :',
+ 'Database size:' => 'Μέγεθος βάσης δεδομένων :',
+ 'Download the database' => 'Κατεβάστε τη βάση δεδομένων',
+ 'Optimize the database' => 'Βελτιστοποίηση της βάσης δεδομένων',
+ '(VACUUM command)' => '(VACUUM command)',
+ '(Gzip compressed Sqlite file)' => '(Gzip compressed Sqlite file)',
+ 'Close a task' => 'Κλείσιμο εργασίας',
+ 'Edit a task' => 'Διόρθωση εργασίας',
+ 'Column' => 'Στήλη',
+ 'Color' => 'Χρώμα',
+ 'Assignee' => 'Ανατεθιμένα',
+ 'Create another task' => 'Δημιουργία και άλλης εργασίας',
+ 'New task' => 'Νέα εργασία',
+ 'Open a task' => 'Άνοιγμα εργασίας',
+ 'Do you really want to open this task: "%s"?' => 'Άνοιγμα της εργασίας : « %s » ?',
+ 'Back to the board' => 'Επιστροφή στον κεντρικό πίνακα έργου',
+ 'There is nobody assigned' => 'Δεν έχει ανατεθεί σε κανένα',
+ 'Column on the board:' => 'Στήλη στον κεντρικό πίνακα : ',
+ 'Close this task' => 'Κλείσιμο εργασίας',
+ 'Open this task' => 'Άνοιγμα εργασίας',
+ 'There is no description.' => 'Δεν υπάρχει περιγραφή.',
+ 'Add a new task' => 'Προσθήκη νέας εργασίας',
+ 'The username is required' => 'Το όνομα χρήστη απαιτείται',
+ 'The maximum length is %d characters' => 'Ο μέγιστος αριθμός χαρακτήρων είναι %d χαρακτήρες',
+ 'The minimum length is %d characters' => 'Ο ελάχιστος αριθμός χαρακτήρων είναι %d χαρακτήρες',
+ 'The password is required' => 'Ο κωδικός απαιτείται',
+ 'This value must be an integer' => 'Η τιμή πρέπει να είναι ακέραιος',
+ 'The username must be unique' => 'Το όνομα χρήστη πρέπει να είναι μοναδικό',
+ 'The user id is required' => 'το αναγνωριστικό χρήστη απαιτείται',
+ 'Passwords don\'t match' => 'Οι κωδικοί πρόσβασης δεν ταιριάζουν',
+ 'The confirmation is required' => 'Η επειβεβαίωση απαιτείται',
+ 'The project is required' => 'Το έργο απαιτείται',
+ 'The id is required' => 'το αναγνωριστικό απαιτείται',
+ 'The project id is required' => 'το αναγνωριστικό έργου απαιτείται',
+ 'The project name is required' => 'Η ονομασία έργου απαιτείται',
+ 'The title is required' => 'Ο τίτλος απαιτείται',
+ 'Settings saved successfully.' => 'Οι προτιμήσεις αποθηκεύθηκαν με επιτυχία.',
+ 'Unable to save your settings.' => 'Αδυναμία αποθήκευσης προτιμήσεων.',
+ 'Database optimization done.' => 'Η βελτιστοποίηση της βάσης δεδομένων έγινε με επιτυχία.',
+ 'Your project have been created successfully.' => 'Το έργο δημιουργήθηκε.',
+ 'Unable to create your project.' => 'Δεν είναι δυνατή η δημιουργία του έργου',
+ 'Project updated successfully.' => 'Το έργο ενημερώθηκε με επιτυχία.',
+ 'Unable to update this project.' => 'Δεν είναι δυνατή η ενημέρωση του έργου.',
+ 'Unable to remove this project.' => 'Δεν είναι δυνατή η διαγραφή του έργου',
+ 'Project removed successfully.' => 'Το έργο αφαιρέθηκε επιτυχώς.',
+ 'Project activated successfully.' => 'Το έργο ενεργοποιήθηκε με επιτυχία',
+ 'Unable to activate this project.' => 'Δεν είναι δυνατή η ενεργοποίηση του έργου',
+ 'Project disabled successfully.' => 'Το έργο ενεργοποιήθηκε με επιτυχία',
+ 'Unable to disable this project.' => 'Δεν είναι δυνατή η επενεργοποίηση του έργου.',
+ 'Unable to open this task.' => 'Δεν είναι δυνατό το άνοιγμα της εργασίας',
+ 'Task opened successfully.' => 'Η εργασία άνοιξε με επιτυχία',
+ 'Unable to close this task.' => 'Δεν είναι δυνατό το κλείσιμο της εργασίας',
+ 'Task closed successfully.' => 'Η εργασία έκλεισε επιτυχώς',
+ 'Unable to update your task.' => 'Δεν είναι δυνατή η ενημέρωση της εργασίας',
+ 'Task updated successfully.' => 'Η εργασία ενημερώθηκε επιτυχώς',
+ 'Unable to create your task.' => 'Δεν είναι δυνατή η δημιουργία της εργασίας',
+ 'Task created successfully.' => 'Η εργασία δημιουργήθηκε επιτυχώς',
+ 'User created successfully.' => 'Ο χρήστης δημιουργήθηκε με επιτυχία',
+ 'Unable to create your user.' => 'Δεν είναι δυνατή η δημιουργία χρήστη',
+ 'User updated successfully.' => 'Ο χρήστης ενημερωθηκε με επιτυχία',
+ 'Unable to update your user.' => 'Δεν είναι δυνατή η ενημέρωση του χρήστη',
+ 'User removed successfully.' => 'Ο χρήστης αφαιρέθηκε με επιτυχία.',
+ 'Unable to remove this user.' => 'Δεν είναι δυνατή η αφαίρεση χρήστη.',
+ 'Board updated successfully.' => 'Ο πίνακας ενημερώθηκε με επιτυχία.',
+ 'Ready' => 'Έτοιμο',
+ 'Backlog' => 'Πρέπει να',
+ 'Work in progress' => 'Σε πρόοδο',
+ 'Done' => 'Ολοκληρωμένα',
+ 'Application version:' => 'Version εφαρμογής :',
+ 'Id' => 'Αναγνωριστικό.',
+ '%d closed tasks' => '%d κλειστές εργασίες',
+ 'No task for this project' => 'Αριθμός εργασιών για το έργο',
+ 'Public link' => 'Δημόσιος σύνδεσμος',
+ 'Change assignee' => 'Αλλαγή ανάθεσης',
+ 'Change assignee for the task "%s"' => 'Αλλαγή ανάθεσης για την εργασία « %s »',
+ 'Timezone' => 'Timezone',
+ 'Sorry, I didn\'t find this information in my database!' => 'Δυστυχώς δεν βρέθηκε αυτή η πληροφορία στη βάση δεδομένων',
+ 'Page not found' => 'Η σελίδα δεν βρέθηκε',
+ 'Complexity' => 'Πολυπλοκότητα',
+ 'Task limit' => 'Όριο εργασιών.',
+ 'Task count' => 'Αρίθμηση εργασιών',
+ 'User' => 'Χρήστης',
+ 'Comments' => 'Σχόλια',
+ 'Write your text in Markdown' => 'Δυνατότητα γραφής και σε Markdown',
+ 'Leave a comment' => 'Αφήστε ένα σχόλιο',
+ 'Comment is required' => 'Το σχόλιο απαιτείται',
+ 'Leave a description' => 'Αφήστε μια περιγραφή',
+ 'Comment added successfully.' => 'Το σχόλιο σας προστέθηκε με επιτυχία.',
+ 'Unable to create your comment.' => 'Δεν είναι δυνατή η προσθήκη του σχολίου σας.',
+ 'Edit this task' => 'Διόρθωση εργασίας',
+ 'Due Date' => 'Μέχρι την ημερομηνία',
+ 'Invalid date' => 'Μη ορθή ημερομηνία',
+ 'Automatic actions' => 'Αυτόματες ενέργειες',
+ 'Your automatic action have been created successfully.' => 'Η αυτόματη ενέργεια δημιουργήθηκε με επιτυχία.',
+ 'Unable to create your automatic action.' => 'Impossible de créer votre action automatisée.',
+ 'Remove an action' => 'Αφαίρεση ενέργειας',
+ 'Unable to remove this action.' => 'Δεν είναι δυνατή η αφαίρεση αυτής της ενέργειας',
+ 'Action removed successfully.' => 'Η ενέργεια αφαιρέθηκε με επιτυχία.',
+ 'Automatic actions for the project "%s"' => 'Αυτόματες ενέργειες για το έργο « %s »',
+ 'Defined actions' => 'Ορισμένες ενέργειες',
+ 'Add an action' => 'Προσθήκη ενέργειας',
+ 'Event name' => 'Ονομασία συμβάντος',
+ 'Action name' => 'Ονομασία ενέργειας',
+ 'Action parameters' => 'Παράμετροι ενέργειας',
+ 'Action' => 'Ενέργεια',
+ 'Event' => 'Συμβάν',
+ 'When the selected event occurs execute the corresponding action.' => 'Όταν εμφανίζεται το επιλεγμένο συμβάν εκτελέστε την αντίστοιχη ενέργεια.',
+ 'Next step' => 'Επόμενο βήμα',
+ 'Define action parameters' => 'Ορισμός παραμέτρων ενέργειας',
+ 'Save this action' => 'Αποθήκευση ενέργειας',
+ 'Do you really want to remove this action: "%s"?' => 'Αφαίρεση της ενέργειας: « %s » ?',
+ 'Remove an automatic action' => 'Αφαίρεση της αυτόματης ενέργειας',
+ 'Assign the task to a specific user' => 'Ανάθεση της εργασίας σε συγκεκριμένο χρήστη',
+ 'Assign the task to the person who does the action' => 'Ανάθεση της εργασίας στο άτομο που κάνει την ενέργεια',
+ 'Duplicate the task to another project' => 'Αντιγραφή εργασίας σε άλλο έργο',
+ 'Move a task to another column' => 'Μεταφορά εργασίας σε άλλη στήλη',
+ 'Task modification' => 'Διόρθωση εργασίας',
+ 'Task creation' => 'Δημιουργία εργασίας',
+ 'Closing a task' => 'Κλείσιμο εργασίας',
+ 'Assign a color to a specific user' => 'Ανάθεση χρώματος σε συγκεκριμένο χρήστη',
+ 'Column title' => 'Τίτλος στήλης',
+ 'Position' => 'Θέση',
+ 'Duplicate to another project' => 'Αντιγραφή σε άλλο έργο',
+ 'Duplicate' => 'Αντιγραφή',
+ 'link' => 'σύνδεσμος',
+ 'Comment updated successfully.' => 'Το σχόλιο ενημερώθηκε με επιτυχία.',
+ 'Unable to update your comment.' => 'Αδυναμία ενημέρωσης σχολίου.',
+ 'Remove a comment' => 'Διαγραφή σχολίου',
+ 'Comment removed successfully.' => 'Το σχόλιο διαγράφτηκε με επιτυχία.',
+ 'Unable to remove this comment.' => 'Αδυναμία διαγραφής σχολίου.',
+ 'Do you really want to remove this comment?' => 'Θέλετε να αφαιρέσετε το σχόλιο ?',
+ 'Current password for the user "%s"' => 'Ο τρέχοντας κωδικός για τον χρήστη « %s »',
+ 'The current password is required' => 'Ο τρέχοντας κωδικός πρόσβασης απαιτείται',
+ 'Wrong password' => 'Λάθος κωδικός πρόσβασης',
+ 'Unknown' => 'Άγνωστο',
+ 'Last logins' => 'Τελευταίες συνδέσεις',
+ 'Login date' => 'Ημερομηνία σύνδεσης',
+ 'Authentication method' => 'Μέθοδος ελέγχου ταυτότητας',
+ 'IP address' => 'Διεύθυνση IP',
+ 'User agent' => 'User agent',
+ 'Persistent connections' => 'Μόνιμες συνδέσεις',
+ 'No session.' => 'Καμμία συνεδρία',
+ 'Expiration date' => 'Ημερομηνία λήξης',
+ 'Remember Me' => 'Remember Me',
+ 'Creation date' => 'Ημερομηνία δημιουργίας',
+ 'Everybody' => 'Όλα',
+ 'Open' => 'Ανοικτά',
+ 'Closed' => 'Κλειστά',
+ 'Search' => 'Αναζήτηση',
+ 'Nothing found.' => 'Δεν βρέθηκε.',
+ 'Due date' => 'Μέχρι την ημερομηνία',
+ 'Others formats accepted: %s and %s' => 'Άλλες δεκτές μορφοποιήσεις : %s και %s',
+ 'Description' => 'Περιγραφή',
+ '%d comments' => '%d σχόλια',
+ '%d comment' => '%d σχόλιο',
+ 'Email address invalid' => 'Μη αποδεκτή διεύθυνση email',
+ 'Your external account is not linked anymore to your profile.' => 'Ο λογαριασμός σας δεν συνδέεται πλέον με το προφίλ σας.',
+ 'Unable to unlink your external account.' => 'Αδυναμία αποσύνδεσης του εξωτερικού σας λογαριασμού.',
+ 'External authentication failed' => 'Αποτυχία εξωτερικής σύνδεσης',
+ 'Your external account is linked to your profile successfully.' => 'Ο λογαριασμός σας συνδέθηκε με το προφίλ σας με επιτυχία.',
+ 'Email' => 'Email',
+ 'Task removed successfully.' => 'Η εργασία αφαιρέθηκε με επιτυχία.',
+ 'Unable to remove this task.' => 'Δεν είναι δυνατή η αφαίρεση της εργασίας.',
+ 'Remove a task' => 'Αφαίρεση εργασίας',
+ 'Do you really want to remove this task: "%s"?' => 'Αφαίρεση της εργασίας « %s » ?',
+ 'Assign automatically a color based on a category' => 'Αυτόματη εκχώρηση ενός χρώματος με βάση την κατηγορία',
+ 'Assign automatically a category based on a color' => 'Αυτόματη εκχώρηση μιας κατηγορίας με βάση το χρώμα',
+ 'Task creation or modification' => 'Δημιουργία ή τροποποίηση εργασιών',
+ 'Category' => 'Κατηγορία',
+ 'Category:' => 'Κατηγορία :',
+ 'Categories' => 'Κατηγορίες',
+ 'Category not found.' => 'Η κατηγορία δεν βρέθηκε',
+ 'Your category have been created successfully.' => 'Η κατηγορία δημιουργήθηκε.',
+ 'Unable to create your category.' => 'Δεν είναι δυνατή η δημιουργία της κατηγορίας.',
+ 'Your category have been updated successfully.' => 'Η ενημέρωση της κατηγορίας καταχωρήθηκε με επιτυχία.',
+ 'Unable to update your category.' => 'Δεν είναι δυνατή η ενημέρωση της κατηγορίας.',
+ 'Remove a category' => 'Διαγραφή κατηγορίας',
+ 'Category removed successfully.' => 'Η κατηγορία διεγράφει επιτυχώς.',
+ 'Unable to remove this category.' => 'Δεν είναι δυνατή η διαγραφή της κατηγορίας.',
+ 'Category modification for the project "%s"' => 'Τροποποίηση κατηγορίας για το έργο « %s »',
+ 'Category Name' => 'Ονομασία κατηγορίας',
+ 'Add a new category' => 'Προσθήκη νέας κατηγορίας',
+ 'Do you really want to remove this category: "%s"?' => 'Διαγραφή της κατηγορίας « %s » ?',
+ 'All categories' => 'Όλες οι κατηγορίες',
+ 'No category' => 'Χωρίς κατηγορία',
+ 'The name is required' => 'Η ονομασία απαιτείται',
+ 'Remove a file' => 'Αφαίρεση αρχείου',
+ 'Unable to remove this file.' => 'Αδυναμία αφαίρεσης αρχείου.',
+ 'File removed successfully.' => 'Το αρχείο αφαιρέθηκε επιτυχώς.',
+ 'Attach a document' => 'Προσθήκη Συνημμένου',
+ 'Do you really want to remove this file: "%s"?' => 'Αφαίρεση του αρχείου: « %s » ?',
+ 'Attachments' => 'Συνημμένα',
+ 'Edit the task' => 'Διόρθωση εργασίας',
+ 'Edit the description' => 'Διόρθωση περιγραφής',
+ 'Add a comment' => 'Προσθήκη σχολίου',
+ 'Edit a comment' => 'Διόρθωση σχολίου',
+ 'Summary' => 'Περίληψη',
+ 'Time tracking' => 'Παρακολούθηση χρόνου',
+ 'Estimate:' => 'Κατ\' εκτίμηση :',
+ 'Spent:' => 'Ξοδεύτηκε :',
+ 'Do you really want to remove this sub-task?' => 'Διαγραφή της υπο-εργασίας ?',
+ 'Remaining:' => 'Απομένει :',
+ 'hours' => 'ώρες',
+ 'spent' => 'ξοδεύτηκε',
+ 'estimated' => 'κατ\' εκτίμηση',
+ 'Sub-Tasks' => 'Υπο-Εργασίες',
+ 'Add a sub-task' => 'Προσθήκη υπο-εργασίας',
+ 'Original estimate' => 'Original estimate',
+ 'Create another sub-task' => 'Δημιουργία κι άλλης υπο-εργασίας',
+ 'Time spent' => 'Χρόνος που ξοδεύτηκε',
+ 'Edit a sub-task' => 'Διόρθωση υπο-εργασίας',
+ 'Remove a sub-task' => 'Διαγραφή υπο-εργασίας',
+ 'The time must be a numeric value' => 'Ο χρόνος πρέπει να είναι αριθμός',
+ 'Todo' => 'Πρέπει να γίνουν',
+ 'In progress' => 'Σε πρόοδο',
+ 'Sub-task removed successfully.' => 'Η υπο-εργασία αφαιρέθηκε με επιτυχία.',
+ 'Unable to remove this sub-task.' => 'Αδυναμία αφαίρεσης υπο-εργασίας.',
+ 'Sub-task updated successfully.' => 'Η υπο-εργασία ενημερώθηκε με επιτυχία.',
+ 'Unable to update your sub-task.' => 'Αδύνατο να ενημερωθεί η υπο-εργασία.',
+ 'Unable to create your sub-task.' => 'Αδύνατο να δημιουργηθεί η υπο-εργασία.',
+ 'Sub-task added successfully.' => 'Η υπο-εργασία προστέθηκε με επιτυχία.',
+ 'Maximum size: ' => 'Μέγιστο μέγεθος : ',
+ 'Unable to upload the file.' => 'Δεν είναι δυνατή η μεταφόρτωση του αρχείου.',
+ 'Display another project' => 'Εμφάνιση άλλου έργου',
+ 'Created by %s' => 'Δημιουργήθηκε από %s',
+ 'Tasks Export' => 'Εξαγωγή εργασιών',
+ 'Tasks exportation for "%s"' => 'Εξαγωγή εργασιών για το έργο « %s »',
+ 'Start Date' => 'Ημερομηνία έναρξης',
+ 'End Date' => 'ημερομηνία λήξης',
+ 'Execute' => 'Εκτέλεση',
+ 'Task Id' => 'Task Id',
+ 'Creator' => 'Δημιουργός',
+ 'Modification date' => 'Ημερομηνία τροποποίησης',
+ 'Completion date' => 'Ημερομηνία ολοκλήρωσης',
+ 'Clone' => 'Clone',
+ 'Project cloned successfully.' => 'Το έργο κλωνοποιήθηκε με επιτυχία.',
+ 'Unable to clone this project.' => 'Αδύνατο να κλωνοποιηθεί το έργο.',
+ 'Enable email notifications' => 'Ενεργοποίηση ειδοποιήσεων ηλεκτρονικού ταχυδρομείου',
+ 'Task position:' => 'Θέση έργου :',
+ 'The task #%d have been opened.' => 'Η εργασία #%d έχει ανοίξει.',
+ 'The task #%d have been closed.' => 'Η εργασία #%d έχει κλείσει.',
+ 'Sub-task updated' => 'Η υπο-εργασία ενημερώθηκε',
+ 'Title:' => 'Τίτλος :',
+ 'Status:' => 'Κατάσταση :',
+ 'Assignee:' => 'Εντολοδόχος :',
+ 'Time tracking:' => 'Παρακολούθηση του χρόνου :',
+ 'New sub-task' => 'Νέα υπο-εργασία',
+ 'New attachment added "%s"' => 'Νέα επικόλληση προστέθηκε « %s »',
+ 'Comment updated' => 'Το σχόλιο ενημερώθηκε',
+ 'New comment posted by %s' => 'Νέο σχόλιο από τον χρήστη « %s »',
+ 'New attachment' => 'New attachment',
+ 'New comment' => 'Νέο σχόλιο',
+ 'New subtask' => 'Νέα υπο-εργασία',
+ 'Subtask updated' => 'Υπο-Εργασία ενημερώθηκε',
+ 'Task updated' => 'Η εργασία ενημερώθηκε',
+ 'Task closed' => 'Η εργασία έκλεισε',
+ 'Task opened' => 'Η εργασία άνοιξε',
+ 'I want to receive notifications only for those projects:' => 'Θέλω να ενημερώνομαι αποκλειστικά για:',
+ 'view the task on Kanboard' => 'Προβολή της εργασίας στο Kanboard',
+ 'Public access' => 'Ανοιχτή πρόσβαση',
+ 'Active tasks' => 'Ενεργοποιημένες εργασίες',
+ 'Disable public access' => 'Απενεργοποίηση δημόσιας πρόσβασης',
+ 'Enable public access' => 'Ενεργοποίηση δημόσιας πρόσβασης',
+ 'Public access disabled' => 'Δημόσια πρόσβαση απενεργοποιήθηκε',
+ 'Do you really want to disable this project: "%s"?' => 'Θέλετε πραγματικά να απενεργοποιήσετε το έργο : « %s » ?',
+ 'Do you really want to enable this project: "%s"?' => 'Θέλετε πραγματικά να ενεργοποιήσετε το έργο : « %s » ?',
+ 'Project activation' => 'Ενεργοποίηση έργου',
+ 'Move the task to another project' => 'Μεταφορά της εργασίας σε άλλο έργο',
+ 'Move to another project' => 'Μεταφορά σε άλλο έργο',
+ 'Do you really want to duplicate this task?' => 'Θέλετε πραγματικά να επαναλάβετε αυτή την εργασία;?',
+ 'Duplicate a task' => 'Επανάληψη εργασίας',
+ 'External accounts' => 'Εξωτερικοί λογαριασμοί',
+ 'Account type' => 'Τύπος λογαριασμού',
+ 'Local' => 'Τοπική',
+ 'Remote' => 'Απομακρυσμένη',
+ 'Enabled' => 'Ενεργή',
+ 'Disabled' => 'Απενεργοποιημένη',
+ 'Username:' => 'Username :',
+ 'Name:' => 'Όνομα :',
+ 'Email:' => 'Email :',
+ 'Notifications:' => 'Ειδοποιήσεις :',
+ 'Notifications' => 'Ειδοποιήσεις',
+ 'Account type:' => 'Τύπος λογαριασμού :',
+ 'Edit profile' => 'Επεξεργασία προφίλ',
+ 'Change password' => 'Αλλαγή password',
+ 'Password modification' => 'Τροποποίηση password ',
+ 'External authentications' => 'Εξωτερικές πιστοποιήσεις',
+ 'Never connected.' => 'Ποτέ δεν συνδέθηκε.',
+ 'No external authentication enabled.' => 'Καμία εξωτερική πιστοποιήση ενεργοποιημένη.',
+ 'Password modified successfully.' => 'Το password τροποποιήθηκε με επιτυχία.',
+ 'Unable to change the password.' => 'Αδύνατο να αλλάξει το password.',
+ 'Change category for the task "%s"' => 'Αλλαγή κατηγορίας της εργασίας « %s »',
+ 'Change category' => 'Αλλαγή κατηγορίας',
+ '%s updated the task %s' => '%s ενημέρωσε την εργασία %s',
+ '%s opened the task %s' => '%s άνοιξε την εργασία %s',
+ '%s moved the task %s to the position #%d in the column "%s"' => '%s άλλαξε την εργασία %s στη θέση n°%d στη στήλη « %s »',
+ '%s moved the task %s to the column "%s"' => '%s άλλαξε την εργασία %s στη στήλη « %s »',
+ '%s created the task %s' => '%s δημιούργησε την εργασία %s',
+ '%s closed the task %s' => '%s έκλεισε την εργασία %s',
+ '%s created a subtask for the task %s' => '%s δημιούργησε την υπο-εργασία στην εργασία %s',
+ '%s updated a subtask for the task %s' => '%s ενημέρωση την υπο-εργασία στην εργασία %s',
+ 'Assigned to %s with an estimate of %s/%sh' => 'Ανατέθηκε στον %s με μια εκτίμηση του %s/%sh',
+ 'Not assigned, estimate of %sh' => 'Δεν έχει εκχωρηθεί, εκτίμηση %sh',
+ '%s updated a comment on the task %s' => '%s ενημερώθηκε ένα σχόλιο στην εργασία %s',
+ '%s commented the task %s' => '%s σχολίασε την εργασία %s',
+ '%s\'s activity' => 'δραστηριότητα του έργου %s',
+ 'RSS feed' => 'RSS feed',
+ '%s updated a comment on the task #%d' => '%s ενημέρωσε ένα σχόλιο στην εργασία n°%d',
+ '%s commented on the task #%d' => '%s σχολίασε την εργασία n°%d',
+ '%s updated a subtask for the task #%d' => '%s ενημέρωσε μια υπο-εργασία στην εργασία n °%d',
+ '%s created a subtask for the task #%d' => '%s δημιούργησε μια υπο-εργασία στην εργασία n°%d',
+ '%s updated the task #%d' => '%s ενημέρωσε την εργασία n°%d',
+ '%s created the task #%d' => '%s δημιούργησε την εργασία n°%d',
+ '%s closed the task #%d' => '%s έκλεισε την εργασία n°%d',
+ '%s open the task #%d' => '%s άνοιξε την εργασία n°%d',
+ '%s moved the task #%d to the column "%s"' => '%s μετακίνησε την εργασία n°%d στη στήλη « %s »',
+ '%s moved the task #%d to the position %d in the column "%s"' => '%s μετακίνησε την εργασία n°%d στη θέση n°%d της στήλης « %s »',
+ 'Activity' => 'Δραστηριότητα',
+ 'Default values are "%s"' => 'Οι προεπιλεγμένες τιμές είναι « %s »',
+ 'Default columns for new projects (Comma-separated)' => 'Προεπιλεγμένες στήλες για νέα έργα (Comma-separated)',
+ 'Task assignee change' => 'Αλλαγή εκδοχέα εργασίας',
+ '%s change the assignee of the task #%d to %s' => '%s άλλαξε τον εκδοχέα της εργασίας n˚%d σε %s',
+ '%s changed the assignee of the task %s to %s' => '%s ενημέρωσε τον εκδοχέα της εργασίας %s σε %s',
+ 'New password for the user "%s"' => 'Νέο password του χρήστη « %s »',
+ 'Choose an event' => 'Επιλογή event',
+ 'Create a task from an external provider' => 'Δημιουργήστε μια εργασία από ένα εξωτερικό πάροχο',
+ 'Change the assignee based on an external username' => 'Αλλάξτε τον εκδοχέα βάση ενός εξωτερικού username',
+ 'Change the category based on an external label' => 'Αλλάξτε τον εκδοχέα βάση ενός εξωτερικού label',
+ 'Reference' => 'Reference',
+ 'Label' => 'Label',
+ 'Database' => 'Database',
+ 'About' => 'About',
+ 'Database driver:' => 'Database driver :',
+ 'Board settings' => 'Board settings',
+ 'URL and token' => 'URL / token',
+ 'Webhook settings' => 'Webhook settings',
+ 'URL for task creation:' => 'URL για δημιουργία εργασίας: :',
+ 'Reset token' => 'Reset token',
+ 'API endpoint:' => 'URL API :',
+ 'Refresh interval for private board' => 'Ανανέωση interval στο private board',
+ 'Refresh interval for public board' => 'Ανανέωση interval στο 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' => 'Application URL',
+ 'Token regenerated.' => 'Token regenerated.',
+ 'Date format' => 'Μορφή ημερομηνίας',
+ 'ISO format is always accepted, example: "%s" and "%s"' => 'ISO format είναι πάντα αποδεκτό, π.χ. : « %s » και « %s »',
+ 'New private project' => 'Νέο ιδιωτικό έργο',
+ 'This project is private' => 'Αυτό το έργο είναι ιδιωτικό',
+ 'Type here to create a new sub-task' => 'Πληκτρολογήστε εδώ για να δημιουργήσετε μια νέα υπο-εργασία',
+ 'Add' => 'Προσθήκη',
+ 'Start date' => 'Ημερομηνία έναρξης',
+ 'Time estimated' => 'Εκτιμώμενος χρόνος',
+ 'There is nothing assigned to you.' => 'Δεν σας έχει ανατεθεί τίποτα.',
+ 'My tasks' => 'Οι εργασίες μου',
+ 'Activity stream' => 'Ροή δραστηριότητας',
+ 'Dashboard' => 'Κεντρικό ταμπλό',
+ 'Confirmation' => 'Επιβεβαίωση',
+ 'Allow everybody to access to this project' => 'Να επιτρέπετε σε όλους να έχουν πρόσβαση σε αυτό το έργο',
+ 'Everybody have access to this project.' => 'Όλοι έχουν πρόσβαση σε αυτό το έργο.',
+ 'Webhooks' => 'Webhooks',
+ 'API' => 'API',
+ 'Create a comment from an external provider' => 'Δημιουργήστε ένα σχόλιο από έναν εξωτερικό πάροχο',
+ 'Project management' => 'Διαχείριση έργων',
+ 'My projects' => 'Τα έργα μου',
+ 'Columns' => 'Στήλες',
+ 'Task' => 'Εργασία',
+ 'Your are not member of any project.' => 'Δεν είστε μέλος κάποιου έργου.',
+ 'Percentage' => 'Ποσοστό',
+ 'Number of tasks' => 'Αριθμός εργασιών',
+ 'Task distribution' => 'Κατανομή εργασιών',
+ 'Reportings' => 'Αναφορές',
+ 'Task repartition for "%s"' => 'Επανάληψη εργασιών για « %s »',
+ 'Analytics' => 'Αναλύσεις',
+ 'Subtask' => 'Υπο-Εργασία',
+ 'My subtasks' => 'Οι υπο-εργασίες μου',
+ 'User repartition' => 'Επαναλήψεις χρηστών',
+ 'User repartition for "%s"' => 'Επαναλήψεις χρηστών για « %s »',
+ 'Clone this project' => 'Κλωνοποίηση έργου',
+ 'Column removed successfully.' => 'Η στήλη αφαιρέθηκε με επιτυχία.',
+ 'Not enough data to show the graph.' => 'Ελλειπή δεδομένα για να εμφανιστεί το γράφημα.',
+ 'Previous' => 'Προηγούμενο',
+ 'The id must be an integer' => 'Το id δεν πρέπει να είναι ακέραιος',
+ 'The project id must be an integer' => 'Το αναγνωριστικό έργου πρέπει να είναι ακέραιος',
+ 'The status must be an integer' => 'Το status πρέπει να είναι ακέραιος',
+ 'The subtask id is required' => 'Το id της υπο-εργασίας είναι υποχρεωτικό',
+ 'The subtask id must be an integer' => 'Το id της υπο-εργασίας πρέπει να είναι ακέραιος',
+ 'The task id is required' => 'Το id της εργασίας είναι υποχρεωτικό',
+ 'The task id must be an integer' => 'Το id της εργασίας πρέπει να είναι ακέραιος',
+ 'The user id must be an integer' => 'Το user id πρέπει να είναι ακέραιος',
+ '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' => 'Write',
+ 'Active swimlanes' => 'Ενεργά swimlanes',
+ 'Add a new swimlane' => 'Πρόσθεσε ένα νέο λωρίδα',
+ 'Change default swimlane' => 'Αλλαγή του default λωρίδα',
+ 'Default swimlane' => 'Default λωρίδα',
+ 'Do you really want to remove this swimlane: "%s"?' => 'Σίγουρα θέλετε να αφαιρέσετε τη λωρίδα : « %s » ?',
+ 'Inactive swimlanes' => 'Λωρίδες ανενεργές',
+ 'Remove a swimlane' => 'Αφαιρέστε μια λωρίδα',
+ 'Show default swimlane' => 'Εμφάνιση προεπιλεγμένων λωρίδων',
+ 'Swimlane modification for the project "%s"' => 'Τροποποίηση λωρίδας για το έργο « %s »',
+ 'Swimlane not found.' => 'Η λωρίδα δεν βρέθηκε.',
+ 'Swimlane removed successfully.' => 'Η λωρίδα αφαιρέθηκε με επιτυχία.',
+ 'Swimlanes' => 'Swimlanes',
+ 'Swimlane updated successfully.' => 'Η λωρίδα ενημερώθηκε με επιτυχία.',
+ 'The default swimlane have been updated successfully.' => 'Η προεπιλεγμένη λωρίδα ενημερώθηκε με επιτυχία.',
+ '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)' => 'Προεπιλεγμένες κατηγορίες για νέα έργα (Διαχωρισμένων με κόμμα)',
+ 'Integrations' => 'Ενσωματώσεις',
+ 'Integration with third-party services' => 'Ενοποίηση με υπηρεσίες τρίτων',
+ 'Subtask Id' => 'Id υπο-εργασίας',
+ 'Subtasks' => 'Υπο-Εργασίες',
+ 'Subtasks Export' => 'Εξαγωγή υπο-εργασίων',
+ 'Subtasks exportation for "%s"' => 'Εξαγωγή υπο-εργασίων για το έργο « %s »',
+ 'Task Title' => 'Τίτλος εργασίας',
+ 'Untitled' => 'Χωρίς τίτλο',
+ 'Application default' => 'Προεπιλογή από την εφαρμογή',
+ 'Language:' => 'Γλώσσα :',
+ 'Timezone:' => 'Timezone :',
+ 'All columns' => 'Όλες οι στήλες',
+ 'Calendar' => 'Ημερολόγιο',
+ 'Next' => 'Επόμενο',
+ '#%d' => 'n˚%d',
+ 'All swimlanes' => 'Όλες οι λωρίδες',
+ 'All colors' => 'Όλα τα χρώματα',
+ '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 »',
+ 'Select the new status of the subtask: "%s"' => 'Επιλογή νέας κατάστασης της υπο-εργασίας : « %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?' => 'Ποιά κομμάτια του έργου θέλετε να αντιγράψετε ?',
+ 'Disallow login form' => 'Απαγόρευση φόρμας σύνδεσης',
+ 'Start' => 'Εκκίνηση',
+ 'End' => 'Τέλος',
+ 'Task age in days' => 'Χρόνος εργασίας σε μέρες',
+ 'Days in this column' => 'Μέρες σε αυτή την στήλη',
+ '%dd' => '%dημ',
+ 'Add a link' => 'Προσθήκη ενός link',
+ 'Add a new link' => 'Προσθήκη ενός νέου link',
+ 'Do you really want to remove this link: "%s"?' => 'Θέλετε σίγουρα να αφαιρέσετε αυτό το link : « %s » ?',
+ 'Do you really want to remove this link with task #%d?' => 'Θέλετε σίγουρα να αφαιρέσετε αυτό το link του έργου n°%d ?',
+ 'Field required' => 'Υποχρεωτικό πεδίο',
+ 'Link added successfully.' => 'Το link προστέθηκε με επιτυχία.',
+ 'Link updated successfully.' => 'Το link ενημερώθηκε με επιτυχία.',
+ 'Link removed successfully.' => 'Το link αφαιρέθηκε με επιτυχία.',
+ 'Link labels' => 'Link labels',
+ 'Link modification' => 'Τροποποίηση Link ',
+ 'Links' => 'Links',
+ 'Link settings' => 'Ρυθμίσεις συνδέσμων',
+ 'Opposite label' => 'Αντίθετο label',
+ 'Remove a link' => 'Αφαίρεση ενός link',
+ 'Task\'s links' => 'Σύνδεσμοι εργασιών',
+ 'The labels must be different' => 'Τα label πρέπει να είναι διαφορετικά',
+ 'There is no link.' => 'Δεν υπάρχει σύνδεσμος.',
+ 'This label must be unique' => 'Το label πρέπει να είναι μοναδικό',
+ 'Unable to create your link.' => 'Αδύνατο να δημιουργήσετε σύνδεσμο.',
+ 'Unable to update your link.' => 'Αδύνατο να ενημερώσετε τον σύνδεσμο.',
+ 'Unable to remove this link.' => 'Αδύνατο να αφαιρέσετε τον σύνδεσμο.',
+ 'relates to' => 'συνδέεται με',
+ 'blocks' => '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' => '<1ωρ',
+ '%dh' => '%dωρ',
+ 'Expand tasks' => 'Ανάπτυξη εργασιών',
+ 'Collapse tasks' => 'Σύμπτυξη εργασιών',
+ 'Expand/collapse tasks' => 'Ανάπτυξη/σύμπτυξη εργασιών',
+ 'Close dialog box' => 'Κλείστε το παράθυρο διαλόγου',
+ 'Submit a form' => 'Αποστολή φόρμας',
+ 'Board view' => 'Προβολή κεντρικού πίνακα',
+ 'Keyboard shortcuts' => 'Συντομεύσεις πληκτρολογίου',
+ 'Open board switcher' => 'Άνοιγμα μεταγωγέα κεντρικού πίνακα',
+ 'Application' => 'Εφαρμογή',
+ 'Compact view' => 'Συμπηκνωμένη προβολή',
+ 'Horizontal scrolling' => 'Οριζόντια ολίσθηση',
+ 'Compact/wide view' => 'Συμπηκνωμένη/Ευρεία Προβολή',
+ 'No results match:' => 'Δεν ταιριάζει κανένα αποτέλεσμα :',
+ 'Currency' => 'Νόμισμα',
+ 'Private project' => 'Ιδιωτικό έργο',
+ 'AUD - Australian Dollar' => 'AUD - Australian Dollar',
+ 'CAD - Canadian Dollar' => 'CAD - Canadian Dollar',
+ 'CHF - Swiss Francs' => 'CHF - Swiss Francs',
+ 'Custom Stylesheet' => 'Custom Stylesheet',
+ 'download' => 'download',
+ 'EUR - Euro' => 'EUR - Euro',
+ 'GBP - British Pound' => 'GBP - British Pound',
+ 'INR - Indian Rupee' => 'INR - Indian Rupee',
+ 'JPY - Japanese Yen' => 'JPY - Japanese Yen',
+ 'NZD - New Zealand Dollar' => 'NZD - New Zealand Dollar',
+ 'RSD - Serbian dinar' => 'RSD - Serbian dinar',
+ 'USD - US Dollar' => 'USD - US Dollar',
+ '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' => 'Πηγή στήλης',
+ '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' => 'Προσθήκη ισοτιμίας',
+ 'Reference currency' => 'Αναφορά ισοτιμίας',
+ 'The currency rate have been added successfully.' => 'Η ισοτιμία προστέθηκε με επιτυχία.',
+ 'Unable to add this currency rate.' => 'Αδύνατο να προστεθεί αυτή η ισοτιμία.',
+ 'Webhook URL' => 'Webhook URL',
+ '%s remove the assignee of the task %s' => '%s αφαίρεσε τον εκδοχέα της εργασίας %s',
+ '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' => 'Κωδικός',
+ 'Two factor authentication' => 'Κωδικός ελέγχου ταυτότητας δύο παραγόντων',
+ 'This QR code contains the key URI: ' => 'Αυτό το QR code περιέχει το url : ',
+ 'Check my code' => 'Έλεγχος του κωδικού μου',
+ 'Secret key: ' => 'Μυστικό κλειδί : ',
+ 'Test your device' => 'Ελέγξτε τη συσκευή σας',
+ 'Assign a color when the task is moved to a specific column' => 'Αντιστοίχιση χρώματος όταν η εργασία κινείται σε μια συγκεκριμένη στήλη',
+ '%s via Kanboard' => '%s via Kanboard',
+ 'Burndown chart for "%s"' => 'Δημιουργία διαγράμματος για « %s »',
+ 'Burndown chart' => 'Δημιουργία διαγράμματος',
+ 'This chart show the task complexity over the time (Work Remaining).' => 'Αυτό το γράφημα δείχνει την πολυπλοκότητα του έργου κατά την πάροδο του χρόνου (Εργασία που παραμένει).',
+ 'Screenshot taken %s' => 'Το screenshot αποθηκεύτηκε από %s',
+ 'Add a screenshot' => 'Προσθήκη ενός screenshot',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Πάρτε ένα screenshot και πατήστε CTRL+V or ⌘+V για να το επικολλήσετε εδώ.',
+ 'Screenshot uploaded successfully.' => 'Το screenshot ανέβηκε με επιτυχία.',
+ 'SEK - Swedish Krona' => 'SEK - Swedish Krona',
+ 'Identifier' => 'Αναγνωριστικό',
+ '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' => 'Επαναλαμβανόμενο έργο έχει προγραμματιστεί να δημιουργηθεί',
+ 'Score' => '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' => 'Όταν το έργο έχει μετακινηθεί στην 1η στήλη',
+ 'When task is moved to last column' => 'Όταν το έργο έχει μετακινηθεί στην τελευταία στήλη',
+ 'Year(s)' => 'Χρόνος(οι)',
+ '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 feed',
+ 'Preferences' => 'Προτιμήσεις',
+ 'Security' => 'Ασφάλεια',
+ 'Two factor authentication disabled' => 'Η πιστοποίηση δύο παραγόντων απενεργοποιήθηκε',
+ 'Two factor authentication enabled' => 'Η πιστοποίηση δύο παραγόντων ενεργοποιήθηκε',
+ 'Unable to update this user.' => 'Αδύνατο να ενημερωθεί αυτός ο χρήστης.',
+ 'There is no user management for private projects.' => 'Δεν υπάρχει διαχείριση χρηστών για ιδιωτικά έργα.',
+ 'User that will receive the email' => 'Ο χρήστης που θα λάβει το μήνυμα ηλεκτρονικού ταχυδρομείου',
+ 'Email subject' => 'Θέμα ηλεκτρονικού ταχυδρομείου',
+ 'Date' => 'Ημέρα',
+ 'Add a comment log when moving the task between columns' => 'Προσθέστε ένα σχόλιο καταγραφής κατά τη μετακίνηση του έργου μεταξύ των στηλών',
+ 'Move the task to another column when the category is changed' => 'Μετακινήστε την εργασία σε άλλη στήλη, όταν η κατηγορία έχει αλλάξει',
+ 'Send a task by email to someone' => 'Στείλτε μια εργασία μέσω ηλεκτρονικού ταχυδρομείου σε κάποιον',
+ 'Reopen a task' => 'Ξανα-ανοίξτε μια εργασία',
+ 'Column change' => 'Αλλαγή στήλης',
+ 'Position change' => 'Αλλαγή θέσης',
+ 'Swimlane change' => 'Αλλαγή λωρίδας',
+ 'Assignee change' => 'Αλλαγή εκδοχέα',
+ '[%s] Overdue tasks' => '[%s] Εκπρόθεσμες εργασίες',
+ 'Notification' => 'Κοινοποίηση',
+ '%s moved the task #%d to the first swimlane' => '%s μετέφερε την εργασία n°%d στην 1η λωρίδα',
+ '%s moved the task #%d to the swimlane "%s"' => '%s μετέφερε την εργασία n°%d στη λωρίδα « %s »',
+ 'Swimlane' => 'Λωρίδα',
+ 'Gravatar' => 'Gravatar',
+ '%s moved the task %s to the first swimlane' => '%s μετέφερε την εργασία %s στην 1η λωρίδα',
+ '%s moved the task %s to the swimlane "%s"' => '%s μετέφερε την εργασία %s στη λωρίδα « %s »',
+ 'This report contains all subtasks information for the given date range.' => 'Η έκθεση αυτή περιέχει όλες τις υπο-εργασίες για το συγκεκριμένο εύρος ημερομηνιών.',
+ 'This report contains all tasks information for the given date range.' => 'Η έκθεση αυτή περιέχει όλες τις πληροφορίες για το συγκεκριμένο εύρος ημερομηνιών.',
+ 'Project activities for %s' => 'Ενέργειες για το έργο « %s »',
+ 'view the board on Kanboard' => 'δείτε τον πίνακα στο Kanboard',
+ 'The task have been moved to the first swimlane' => 'Η εργασία αυτή έχει μετακινηθεί στην πρώτη λωρίδα',
+ 'The task have been moved to another swimlane:' => 'Η εργασία αυτή έχει μετακινηθεί σε άλλη λωρίδα :',
+ 'Overdue tasks for the project "%s"' => 'Εκπρόθεσμες εργασίες για το έργο « %s »',
+ 'New title: %s' => 'Νέος τίτλος : %s',
+ 'The task is not assigned anymore' => 'Η εργασία δεν έχει ανατεθεί πλέον',
+ 'New assignee: %s' => 'Καινούργια ανάθεση : %s',
+ 'There is no category now' => 'Δεν υπάρχει κατηγορία τώρα',
+ 'New category: %s' => 'Νέα κατηγορία : %s',
+ 'New color: %s' => 'Νέο χρώμα : %s',
+ 'New complexity: %d' => 'Νέα πολυπλοκότητα : %d',
+ 'The due date have been removed' => 'Η ημερομηνία καθηκόντων έχει αφαιρεθεί',
+ 'There is no description anymore' => 'Δεν υπάρχει περιγραφή πλέον',
+ 'Recurrence settings have been modified' => 'Οι ρυθμίσεις επανάληψης έχουν τροποποιηθεί',
+ 'Time spent changed: %sh' => 'Ο χρόνος που πέρασε έχει αλλάξει : %sh',
+ 'Time estimated changed: %sh' => 'Ο εκτιμώμενος χρόνος άλλαξε : %sh',
+ 'The field "%s" have been updated' => 'Το πεδίο « %s » έχει ενημερωθεί',
+ 'The description have been modified' => 'Η περιγραφή έχει ενημερωθεί',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'Σίγουρα θέλετε να κλείσετε την εργασία « %s » και την υπο-εργασία ?',
+ 'I want to receive notifications for:' => 'Επιθυμώ να λαμβάνω ενημερώσεις για :',
+ 'All tasks' => 'Όλες οι εργασίες',
+ 'Only for tasks assigned to me' => 'Μόνο σε εργασίες που μου έχουν ανατεθεί',
+ 'Only for tasks created by me' => 'Μόνο σε εργασίες που έχουν δημιουργηθεί από εμένα',
+ 'Only for tasks created by me and assigned to me' => 'Μόνο σε εργασίες που έχουν δημιουργηθεί από εμένα και μου έχουν ανατεθεί',
+ '%%Y-%%m-%%d' => '%%d/%%m/%%Y',
+ 'Total for all columns' => 'Σύνολο για όλες τις στήλες',
+ 'You need at least 2 days of data to show the chart.' => 'Έχετε τουλάχιστον 2 ημέρες δεδομένων για να εμφανιστούν στο διάγραμμα.',
+ '<15m' => '<15m',
+ '<30m' => '<30m',
+ 'Stop timer' => 'Διακοπή ρολογιού',
+ 'Start timer' => 'Έναρξη ρολογιού',
+ 'Add project member' => 'Προσθήκη νέου μέλους έργου',
+ 'Enable notifications' => 'Ενεργοποίηση ειδοποιήσεων',
+ 'My activity stream' => 'Η ροή δραστηριοτήτων μου',
+ 'My calendar' => 'Το ημερολόγιο μου',
+ 'Search tasks' => 'Αναζήτηση εργασιών',
+ 'Back to the calendar' => 'Πίσω στο ημερολόγιο',
+ 'Filters' => 'Φίλτρα',
+ 'Reset filters' => 'Επαναφορά φίλτρων',
+ 'My tasks due tomorrow' => 'Οι εργασίες καθηκόντων μου αύριο',
+ 'Tasks due today' => 'Οι εργασίες καθηκόντων μου αύριο',
+ 'Tasks due tomorrow' => 'Εργασίες καθηκόντων αύριο',
+ 'Tasks due yesterday' => 'Εργασίες καθηκόντων χτές',
+ 'Closed tasks' => 'Κλειστές εργασίες',
+ 'Open tasks' => 'Ανοιχτές εργασίες',
+ 'Not assigned' => 'Δεν έχουν εκχωρηθεί',
+ 'View advanced search syntax' => 'Δείτε τη σύνταξη "αναζήτησης για προχωρημένους"',
+ 'Overview' => 'Επισκόπηση',
+ 'Board/Calendar/List view' => 'Πίνακας / Ημερολόγιο / Προβολή λίστας',
+ 'Switch to the board view' => 'Εναλλαγή στην προβολή του πίνακα',
+ 'Switch to the calendar view' => 'Εναλλαγή στην προβολή ημερολογίου',
+ 'Switch to the list view' => 'Εναλλαγή στην προβολή λίστας',
+ 'Go to the search/filter box' => 'Μετάβαση στο πλαίσιο αναζήτησης / φίλτρο',
+ 'There is no activity yet.' => 'Δεν υπάρχει καμία δραστηριότητα ακόμα.',
+ 'No tasks found.' => 'Δεν βρέθηκαν εργασίες.',
+ 'Keyboard shortcut: "%s"' => 'Συντόμευση πληκτρολογίου : « %s »',
+ 'List' => 'Λίστα',
+ 'Filter' => 'Φίλτρο',
+ 'Advanced search' => 'Προχωρημένη Αναζήτηση',
+ 'Example of query: ' => 'Παράδειγμα ερωτήματος : ',
+ 'Search by project: ' => 'Αναζήτηση με βάση το έργο : ',
+ 'Search by column: ' => 'Αναζήτηση με βάση την στήλη : ',
+ 'Search by assignee: ' => 'Αναζήτηση με βάση τον δικαιοδόχο : ',
+ 'Search by color: ' => 'Αναζήτηση βάση χρώματος : ',
+ 'Search by category: ' => 'Αναζήτηση βάση κατηγορίας : ',
+ 'Search by description: ' => 'Αναζήτηση βάση περιγραφής : ',
+ 'Search by due date: ' => 'Αναζήτηση βάση ημέρας λήξης : ',
+ 'Lead and Cycle time for "%s"' => 'Lead & cycle time για « %s »',
+ 'Average time spent into each column for "%s"' => 'Μέσος χρόνος παραμονής σε κάθε στήλη για « %s »',
+ 'Average time spent into each column' => 'Μέσος χρόνος παραμονής σε κάθε στήλη',
+ 'Average time spent' => 'Μέσος χρόνος που δαπανήθηκε',
+ 'This chart show the average time spent into each column for the last %d tasks.' => 'Αυτό το γράφημα δείχνει ότι ο μέσος χρόνος που δαπανάται σε κάθε στήλη για τις τελευταίες %d εργασίες',
+ 'Average Lead and Cycle time' => 'Average Lead & Cycle time',
+ 'Average lead time: ' => 'Average lead time : ',
+ 'Average cycle time: ' => 'Average cycle time : ',
+ 'Cycle Time' => 'Cycle time',
+ 'Lead Time' => 'Lead time',
+ 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Αυτό το γράφημα δείχνει το average lead and cycle time για τις τελευταίες %d εργασίες κατά τη διάρκεια του χρόνου.',
+ 'Average time into each column' => 'Μέσος χρόνος σε κάθε στήλη',
+ 'Lead and cycle time' => 'Lead et cycle time',
+ 'Lead time: ' => 'Lead time : ',
+ 'Cycle time: ' => 'Cycle time : ',
+ 'Time spent into each column' => 'Ο χρόνος που δαπανήθηκε σε κάθε στήλη',
+ 'The lead time is the duration between the task creation and the completion.' => 'Το <lead time> είναι η διάρκεια μεταξύ της δημιουργίας του έργου και της ολοκλήρωσης του.',
+ 'The cycle time is the duration between the start date and the completion.' => 'Το <cycle time> είναι η διάρκεια μεταξύ της ημερομηνίας εκκίνησης και της ολοκλήρωσης του.',
+ 'If the task is not closed the current time is used instead of the completion date.' => 'Εάν η εργασία δεν έχει κλείσει η τρέχουσα ώρα χρησιμοποιείται αντί της ημερομηνίας ολοκλήρωσης.',
+ 'Set automatically the start date' => 'Ρυθμίστε αυτόματα την ημερομηνία έναρξης',
+ 'Edit Authentication' => 'Επεξεργασία ταυτοποίησης',
+ 'Remote user' => 'Απομακρυσμένος χρήστης',
+ 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Στους απομακρυσμένους χρήστες δεν αποθηκεύονται οι κωδικοί πρόσβασης εντός της βάσης δεδομένων της τρέχουσας εφαρμογής, Παραδείγματα: LDAP, Google and Github accounts.',
+ 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Αν ενεργοποιήσετε την επιλογή "Απαγόρευση φόρμας σύνδεσης", τα στοιχεία που εισάγονται στη φόρμα σύνδεσης αγνοούνται.',
+ 'New remote user' => 'Νέος απομακρυσμένος χρήστης',
+ 'New local user' => 'Νέος τοπικός χρήστης',
+ 'Default task color' => 'Προεπιλογή χρώματος εργασίας',
+ 'This feature does not work with all browsers.' => 'Αυτή η δυνατότητα δεν δουλεύει σε όλους τους browsers',
+ 'There is no destination project available.' => 'Δεν υπάρχει διαθέσιμο κανένα έργο προορισμού.',
+ 'Trigger automatically subtask time tracking' => 'Αυτόματη ενεργοποίηση της παρακολούθησης χρόνου σε υπο-εργασίες',
+ 'Include closed tasks in the cumulative flow diagram' => 'Να συμπεριλαμβάνονται οι κλειστές εργασίες στο συσσωρευτικό διάγραμμα ροής',
+ 'Current swimlane: %s' => 'Τρέχουσα λωρίδα : %s',
+ 'Current column: %s' => 'Τρέχουσα στήλη : %s',
+ 'Current category: %s' => 'Τρέχουσα κατηγορία : %s',
+ 'no category' => 'Καμμία κατηγορία',
+ 'Current assignee: %s' => 'Τρέχον εκδοχέας : %s',
+ 'not assigned' => 'δεν έχει εκχωρηθεί',
+ 'Author:' => 'Συγγραφέας :',
+ 'contributors' => 'συνεισφέροντες',
+ 'License:' => 'Άδεια :',
+ 'License' => 'Άδεια',
+ 'Enter the text below' => 'Πληκτρολογήστε το παρακάτω κείμενο',
+ 'Gantt chart for %s' => 'Gantt διάγραμμα για %s',
+ 'Sort by position' => 'Ταξινόμηση κατά Θέση',
+ 'Sort by date' => 'Ταξινόμηση κατά ημέρα',
+ 'Add task' => 'Προσθήκη εργασίας',
+ 'Start date:' => 'Ημέρα εκκίνησης :',
+ 'Due date:' => 'Ημέρα καθηκόντων :',
+ 'There is no start date or due date for this task.' => 'Δεν υπάρχει ημερομηνία έναρξης ή ημερομηνία λήξης καθηκόντων για το έργο αυτό.',
+ 'Moving or resizing a task will change the start and due date of the task.' => 'Μετακίνηση ή αλλαγή μεγέθους μιας εργασίας θα αλλάξει την ώρα έναρξης και ημερομηνία λήξης της εργασίας.',
+ 'There is no task in your project.' => 'Δεν υπάρχει καμία εργασία στο έργο σας.',
+ 'Gantt chart' => 'Διαγράμματα Gantt',
+ 'People who are project managers' => 'Οι άνθρωποι που είναι οι διευθυντές έργων',
+ 'People who are project members' => 'Οι άνθρωποι που είναι μέλη των έργων',
+ 'NOK - Norwegian Krone' => 'NOK - Norwegian Krone',
+ 'Show this column' => 'Εμφάνιση αυτής της στήλης',
+ 'Hide this column' => 'Απόκρυψη αυτής τη στήλη',
+ 'open file' => 'Άνοιγμα αρχείου',
+ 'End date' => 'Ημερομηνία λήξης',
+ 'Users overview' => 'Επισκόπηση χρηστών',
+ 'Members' => 'Μέλη',
+ 'Shared project' => 'Κοινόχρηστο έργο',
+ 'Project managers' => 'Διευθυντές έργου',
+ 'Gantt chart for all projects' => 'Διάγραμμα Gantt για όλα τα έργα',
+ 'Projects list' => 'Λίστα έργων',
+ 'Gantt chart for this project' => 'Διάγραμμα Gantt για το έργο',
+ 'Project board' => 'Κεντρικός πίνακας έργου',
+ 'End date:' => 'Ημερομηνία λήξης :',
+ 'There is no start date or end date for this project.' => 'Δεν υπάρχει ημερομηνία έναρξης ή λήξης για το έργο αυτό.',
+ 'Projects Gantt chart' => 'Διάγραμμα Gantt έργων',
+ 'Link type' => 'Τύπος συνδέσμου',
+ 'Change task color when using a specific task link' => 'Αλλαγή χρώματος εργασίας χρησιμοποιώντας συγκεκριμένο σύνδεσμο εργασίας',
+ 'Task link creation or modification' => 'Σύνδεσμος δημιουργίας ή τροποποίησης εργασίας',
+ 'Milestone' => 'Ορόσημο',
+ 'Documentation: %s' => 'Τεκμηρίωση : %s',
+ 'Switch to the Gantt chart view' => 'Μεταφορά σε προβολή διαγράμματος Gantt',
+ 'Reset the search/filter box' => 'Αρχικοποίηση του πεδίου αναζήτησης/φιλτραρίσματος',
+ 'Documentation' => 'Τεκμηρίωση',
+ 'Table of contents' => 'Πίνακας περιεχομένων',
+ 'Gantt' => 'Gantt',
+ 'Author' => 'Δημιουργός',
+ 'Version' => 'Έκδοση',
+ 'Plugins' => 'Πρόσθετα',
+ 'There is no plugin loaded.' => 'Δεν έχει φορτωθεί plugin',
+ 'Set maximum column height' => 'Ορισμός μέγιστου ύψους στήλης',
+ 'Remove maximum column height' => 'Αφαίρεση μέγιστου ύψους στήλης',
+ 'My notifications' => 'Οι ειδοποιήσεις μου',
+ 'Custom filters' => 'Φίλτρα ορισμένα από τον χρήστη',
+ 'Your custom filter have been created successfully.' => 'Το παρεμετροποιημένο από τον χρήστη φίλτρο δημιουργήθηκε με επιτυχία',
+ 'Unable to create your custom filter.' => 'Δεν είναι δυνατή η δημιουργία του φίλτρου ορισμένου από τον χρήστη.',
+ 'Custom filter removed successfully.' => 'Το παρεμετροποιημένο από τον χρήστη φίλτρο αφαιρέθηκε με επιτυχία.',
+ 'Unable to remove this custom filter.' => 'Δεν είναι δυνατή η αφαίρεση του παρεμετροποιημένου από τον χρήστη φίλτρου.',
+ 'Edit custom filter' => 'Διόρθωση παρεμετροποιημένου από τον χρήστη φίλτρου',
+ 'Your custom filter have been updated successfully.' => 'Το παρεμετροποιημένο από τον χρήστη φίλτρο, διορθώθηκε με επιτυχία.',
+ 'Unable to update custom filter.' => 'Δεν είναι δυνατή η ενημέρωση του παρεμετροποιημένου από τον χρήστη φίλτρου.',
+ 'Web' => 'Web',
+ 'New attachment on task #%d: %s' => 'Νέο συνημμένο για την εργασία n°%d : %s',
+ 'New comment on task #%d' => 'Νέο σχόλιο για την εργασία n°%d',
+ 'Comment updated on task #%d' => 'Ενημέρωση σχολίου για την εργασία n°%d',
+ 'New subtask on task #%d' => 'Νέα υπο-εργασία για την εργασία n°%d',
+ 'Subtask updated on task #%d' => 'Ενημέρωση υπό-εργασίας για την εργασία n°%d',
+ 'New task #%d: %s' => 'Νέα εργασία n°%d : %s',
+ 'Task updated #%d' => 'Η εργασία n°%d ενημερώθηκε με επιτυχία',
+ 'Task #%d closed' => 'Η εργασία n°%d έκλεισε',
+ 'Task #%d opened' => 'Η εργασία n°%d άνοιξε',
+ 'Column changed for task #%d' => 'Η στήλη άλλαξε για την εργασία n°%d',
+ 'New position for task #%d' => 'Νέα θέση για την εργασία n°%d',
+ 'Swimlane changed for task #%d' => 'Η λωρίδα άλλαξε για την εργασία n°%d',
+ 'Assignee changed on task #%d' => 'Η ανάθεση άλλαξε για την εργασία n°%d',
+ '%d overdue tasks' => '%d εκπρόθεσμες εργασίες',
+ 'Task #%d is overdue' => 'Η εργασία n°%d είναι εκπρόθεσμη',
+ 'No new notifications.' => 'Χωρίς νέες ειδοποιήσεις.',
+ 'Mark all as read' => 'Μαρκάρισμα όλων ως διαβασμένα',
+ 'Mark as read' => 'Μαρκάρισμα ως διαβασμένο',
+ 'Total number of tasks in this column across all swimlanes' => 'Συνολικός αριθμός εργασιών σε αυτήν τη στήλη σε όλες τις λωρίδες',
+ 'Collapse swimlane' => 'Συρίκνωση λωρίδας',
+ 'Expand swimlane' => 'Ανάπτυξη λωρίδας',
+ 'Add a new filter' => 'Προσθήκη νέου φίλτρου',
+ 'Share with all project members' => 'Διαμοίραση με όλους τους χρήστες του έργου',
+ 'Shared' => 'Διαμοιρασμένα',
+ 'Owner' => 'Ιδιοκτήτης',
+ 'Unread notifications' => 'Αδιάβαστες ειδοποιήσεις',
+ 'My filters' => 'Τα φίλτρα μου',
+ 'Notification methods:' => 'Μέθοδοι ειδοποίησης :',
+ 'Import tasks from CSV file' => 'Εισαγωγή εργασιών μέσω αρχείου CSV',
+ 'Unable to read your file' => 'Δεν είναι δυνατή η ανάγνωση του αρχείου',
+ '%d task(s) have been imported successfully.' => '%d η(οι) εργασία(ες) εισήχθησαν με επιτυχία.',
+ 'Nothing have been imported!' => 'Τίποτα δεν εισήχθη',
+ 'Import users from CSV file' => 'Εισαγωγή χρηστών μέσω αρχείου CSV',
+ '%d user(s) have been imported successfully.' => '%d ο(οι) χρήστης(ες) εισήχθησαν με επιτυχία.',
+ 'Comma' => 'Κόμμα',
+ 'Semi-colon' => 'Ερωτηματικό',
+ 'Tab' => 'Tab',
+ 'Vertical bar' => 'Κατακόρυφη μπάρα',
+ 'Double Quote' => 'Διπλά εισαγωγικά',
+ 'Single Quote' => 'Μονά εισαγωγικά',
+ '%s attached a file to the task #%d' => '%s συνημμένο αρχείο στην εργασία n°%d',
+ 'There is no column or swimlane activated in your project!' => 'Δεν υπάρχει στήλη ή λωρίδα ενεργοποιημένη στο έργο !',
+ 'Append filter (instead of replacement)' => 'Προσθήκη φίλτρου (αντί αντικατάσταση)',
+ 'Append/Replace' => 'Προσθήκη/Αντικατάσταση',
+ 'Append' => 'Προσθήκη',
+ 'Replace' => 'Αντικατάσταση',
+ 'Import' => 'Εισαγωγή',
+ 'change sorting' => 'Αλλαγή ταξινόμησης',
+ 'Tasks Importation' => 'Εισαγωγή εργασιών',
+ 'Delimiter' => 'Delimiter',
+ 'Enclosure' => 'Enclosure',
+ 'CSV File' => 'Αρχείο CSV',
+ 'Instructions' => 'Οδηγίες',
+ 'Your file must use the predefined CSV format' => 'Το αρχείο σας πρέπει να χρησιμοποιεί προκαθορισμένη μορφοποίηση CSV',
+ 'Your file must be encoded in UTF-8' => 'Το αρχείο σας πρέπει να έχει κωδικοποίηση χαρακτήρων UTF-8',
+ 'The first row must be the header' => 'Η πρώτη γραμμή πρέπει να είναι η κεφαλίδα',
+ 'Duplicates are not verified for you' => 'Οι Διπλοεγγραφές δεν ελέγχονται',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'Η ημερομηνία λήξης πρέπει να χρησιμοποιεί τη μορφοποίηση ISO : EEEE-MM-HH ή στα αγγλικά YYYY-MM-DD',
+ 'Download CSV template' => 'Κατέβασμα πρότυπου αρχείου CSV',
+ 'No external integration registered.' => 'No external integration registered.',
+ 'Duplicates are not imported' => 'Διπλοεγγραφές δεν εισήχθησαν',
+ 'Usernames must be lowercase and unique' => 'Οι ονομασίες χρηστών πρέπει να είναι σε μικρά γράμματα (lowercase) και μοναδικά',
+ 'Passwords will be encrypted if present' => 'Οι κωδικοί πρόσβασης κρυπτογραφούνται, αν υπάρχουν',
+ '%s attached a new file to the task %s' => '%s νέο συνημμένο αρχείο της εργασίας %s',
+ 'Assign automatically a category based on a link' => 'Ανατίθεται αυτόματα κατηγορία, βασισμένη στον σύνδεσμο',
+ 'BAM - Konvertible Mark' => 'BAM - Konvertible Mark',
+ 'Assignee Username' => 'Δικαιοδόχο όνομα χρήστη',
+ 'Assignee Name' => 'Δικαιοδόχο όνομα',
+ 'Groups' => 'Ομάδες',
+ 'Members of %s' => 'Μέλη του %s',
+ 'New group' => 'Νέα ομάδα',
+ 'Group created successfully.' => 'Η ομάδα δημιουργήθηκε με επιτυχία.',
+ 'Unable to create your group.' => 'Δεν είναι δυνατή η δημιουργία της ομάδας.',
+ 'Edit group' => 'Διόρθωση ομάδας',
+ 'Group updated successfully.' => 'Η ομάδα ενημερώθηκε με επιτυχία.',
+ 'Unable to update your group.' => 'Δεν είναι δυνατή η ενημέρωση της ομάδας.',
+ 'Add group member to "%s"' => 'Προσθήκη του μέλους της ομάδας στην « %s »',
+ 'Group member added successfully.' => 'Το μέλος της ομ΄δας προστέθηκε με επιτυχία.',
+ 'Unable to add group member.' => 'Δεν είναι δυνατή η προσθήκη μέλους ομάδας.',
+ 'Remove user from group "%s"' => 'Αφαίρεση μέλους από την ομάδα « %s »',
+ 'User removed successfully from this group.' => 'Ο χρήστης αφαιρέθηκε με επιτυχία από την ομάδα.',
+ 'Unable to remove this user from the group.' => 'Δεν είναι δυνατή η αφαίρεση του χρήστη από την ομάδα.',
+ 'Remove group' => 'Αφαίρεση ομάδας',
+ 'Group removed successfully.' => 'Η ομάδα αφαιρέθηκε με επιτυχία.',
+ 'Unable to remove this group.' => 'Δεν είναι δυνατή η αφαίρεση της ομάδας.',
+ 'Project Permissions' => 'Επιτρέψεις έργου',
+ 'Manager' => 'Διευθυντής',
+ 'Project Manager' => 'Διευθυντής έργου',
+ 'Project Member' => 'Μέλος σε έργο',
+ 'Project Viewer' => 'Μέλος μόνο για προβολή έργου',
+ 'Your account is locked for %d minutes' => 'Ο λογαριασμός σας κλειδώθηκε για %d λεπτά',
+ 'Invalid captcha' => 'Μη αποδεκτό Captcha',
+ 'The name must be unique' => 'Το όνομα πρέπει να είναι μοναδικό',
+ 'View all groups' => 'Προβολή όλων των ομάδων',
+ 'View group members' => 'Προβολή των μελών της ομάδας',
+ 'There is no user available.' => 'Δεν υπάρχει διαθέσιμος χρήστης',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Αφαίρεση του χρήστη « %s » από την ομάδα « %s » ?',
+ 'There is no group.' => 'Δεν υπάρχει ομάδα.',
+ 'External Id' => 'Εξωτερικό αναγνωριστικό',
+ 'Add group member' => 'Προσθήκη μέλους ομάδας',
+ 'Do you really want to remove this group: "%s"?' => 'Αφαίρεση της ομάδας : « %s » ?',
+ 'There is no user in this group.' => 'Δεν υπάρχει χρήστης σε αυτήν την ομάδα',
+ 'Remove this user' => 'Αφαίρεση χρήστη',
+ 'Permissions' => 'Επιτρέψεις',
+ 'Allowed Users' => 'Χρήστες που επιτρέπονται',
+ 'No user have been allowed specifically.' => 'Δεν υπάρχει χρήστης που να επιτρέπεται συγκεκριμένα.',
+ 'Role' => 'Ρόλος',
+ 'Enter user name...' => 'Εισαγωγή ονόματος χρήστη...',
+ 'Allowed Groups' => 'Ομάδες που επιτρέπονται',
+ 'No group have been allowed specifically.' => 'Δεν υπάρχει ομάδα που να επιτρέπεται συγκεκριμένα.',
+ 'Group' => 'Ομάδα',
+ 'Group Name' => 'Ονομασία ομάδας',
+ 'Enter group name...' => 'Εισαγωγή ονομασίας ομάδας...',
+ 'Role:' => 'Ρόλος :',
+ 'Project members' => 'Μέλη έργου',
+ 'Compare hours for "%s"' => 'Σύγκριση ωρών για « %s »',
+ '%s mentioned you in the task #%d' => '%s αναφέρονται σε εσάς, στη εργασία n°%d',
+ '%s mentioned you in a comment on the task #%d' => '%s αναφέρονται σε εσάς σε σχόλιο, στη εργασίας n°%d',
+ 'You were mentioned in the task #%d' => 'Αναφέρεστε στην εργασία n°%d',
+ 'You were mentioned in a comment on the task #%d' => 'Αναφέρεστε σε σχόλιο, στην εργασία n°%d',
+ 'Mentioned' => 'Αναφέρεται',
+ 'Compare Estimated Time vs Actual Time' => 'Σύγκριση προβλεπόμενου χρόνου vs πραγματικού χρόνου',
+ 'Estimated hours: ' => 'Προβλεπόμενες ώρες : ',
+ 'Actual hours: ' => 'Πραγματικές ώρες : ',
+ 'Hours Spent' => 'Δαπανόμενες ώρες',
+ 'Hours Estimated' => 'Προβλεπόμενες ώρες',
+ 'Estimated Time' => 'Προβλεπόμενος χρόνος',
+ 'Actual Time' => 'Πραγματικός χρόνος',
+ 'Estimated vs actual time' => 'Προβλεπόμενος vs πραγματικός χρόνος',
+ 'RUB - Russian Ruble' => 'RUB - Russian Ruble',
+ 'Assign the task to the person who does the action when the column is changed' => 'Ανάθεση της εργασίας στο άτομο κάνει την ενέργεια όταν η στήλη αλλάζει',
+ 'Close a task in a specific column' => 'Κλείσιμο εργασίας σε συγκεκριμένη στήλη',
+ 'Time-based One-time Password Algorithm' => 'Time-based One-time Password Algorithm',
+ 'Two-Factor Provider: ' => 'Two-Factor Provider : ',
+ 'Disable two-factor authentication' => 'Disable two-factor authentication',
+ 'Enable two-factor authentication' => 'Enable two-factor authentication',
+ 'There is no integration registered at the moment.' => 'There is no integration registered at the moment.',
+ 'Password Reset for Kanboard' => 'Αρχικοποίηση κωδικών πρόσβασης για την εφαρμογή Kanboard',
+ 'Forgot password?' => 'Ξεχάσατε τον κωδικό πρόσβασης ?',
+ 'Enable "Forget Password"' => 'Ενεργοποίηση « Ξέχασα τον κωδικό πρόσβασης »',
+ 'Password Reset' => 'Αρχικοποίηση κωδικού πρόσβασης',
+ 'New password' => 'Νέος κωδικός πρόσβασης',
+ 'Change Password' => 'Αλλαγή κωδικού πρόσβασης',
+ 'To reset your password click on this link:' => 'Για να αρχικοποιηθεί ο κωδικός πρόσβασης σας πατήστε σε αυτόν τον σύνδεσμο :',
+ 'Last Password Reset' => 'Αρχικοποίηση τελευταίου κωδικού πρόσβασης',
+ 'The password has never been reinitialized.' => 'Ο κωδικός πρόσβασης δεν μπορεί να αρχικοποιηθεί για δεύτερη φορά.',
+ 'Creation' => 'Δημιουργία',
+ 'Expiration' => 'Λήξη',
+ 'Password reset history' => 'Ιστορικό αρχικοποίησης κωδικών πρόσβασης',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Όλες οι εργασίας της στήλης « %s » και η λωρίδα « %s » έκλεισαν με επιτυχία.',
+ 'Do you really want to close all tasks of this column?' => 'Να κλείσουν όλες οι εργασίες αυτής της στήλης ?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d εργασία(ες) στη στήλη « %s » και στη λωρίδα « %s » θα κλείσουν.',
+ 'Close all tasks of this column' => 'Κλείσιμο όλων των εργασιών αυτής της στήλης',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Κανένα plugin δεν έχει καταχωρηθεί με τη μέθοδο της κοινοποίησης του έργου. Μπορείτε ακόμα να διαμορφώσετε τις μεμονωμένες κοινοποιήσεις στο προφίλ χρήστη σας.',
+ 'My dashboard' => 'Το κεντρικό ταμπλό μου',
+ 'My profile' => 'Το προφίλ μου',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
+);
diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php
index 17554a3e..46cbb151 100644
--- a/app/Locale/es_ES/translations.php
+++ b/app/Locale/es_ES/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Suprimir el proyecto',
'Edit the board for "%s"' => 'Modificar el tablero para « %s »',
'All projects' => 'Todos los proyectos',
- 'Change columns' => 'Cambiar las columnas',
'Add a new column' => 'Añadir una nueva columna',
'Title' => 'Título',
'Nobody assigned' => 'Nadie asignado',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Abrir una tarea',
'Do you really want to open this task: "%s"?' => '¿Realmente desea abrir esta tarea: « %s » ?',
'Back to the board' => 'Volver al tablero',
- 'Created on %B %e, %Y at %k:%M %p' => 'Creado el %e de %B de %Y a las %k:%M %p',
'There is nobody assigned' => 'No hay nadie asignado a esta tarea',
'Column on the board:' => 'Columna en el tablero: ',
- 'Status is open' => 'Estado abierto',
- 'Status is closed' => 'Estado cerrado',
'Close this task' => 'Cerrar esta tarea',
'Open this task' => 'Abrir esta tarea',
'There is no description.' => 'No hay descripción.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'El identificador es obligatorio',
'The project id is required' => 'El identificador del proyecto es obligatorio',
'The project name is required' => 'El nombre del proyecto es obligatorio',
- 'This project must be unique' => 'El nombre del proyecto debe ser único',
'The title is required' => 'El título es obligatorio',
'Settings saved successfully.' => 'Parámetros guardados correctamente.',
'Unable to save your settings.' => 'No se pueden guardar sus parámetros.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'En curso',
'Done' => 'Hecho',
'Application version:' => 'Versión de la aplicación:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Completado el %e de %B de %Y a las %k:%M %p',
- '%B %e, %Y at %k:%M %p' => '%e de %B de %Y a las %k:%M %p',
- 'Date created' => 'Fecha de creación',
- 'Date completed' => 'Fecha de terminación',
'Id' => 'Identificador',
'%d closed tasks' => '%d tareas completadas',
'No task for this project' => 'Ninguna tarea para este proyecto',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Complejidad',
'Task limit' => 'Número máximo de tareas',
'Task count' => 'Contador de tareas',
- 'Edit project access list' => 'Editar los permisos del proyecto',
- 'Allow this user' => 'Autorizar a este usuario',
- 'Don\'t forget that administrators have access to everything.' => 'No olvide que los administradores tienen acceso a todo.',
- 'Revoke' => 'Revocar',
- 'List of authorized users' => 'Lista de los usuarios autorizados',
'User' => 'Usuario',
- 'Nobody have access to this project.' => 'Nadie tiene acceso a este proyecto',
'Comments' => 'Comentarios',
'Write your text in Markdown' => 'Redacta el texto en Markdown',
'Leave a comment' => 'Dejar un comentario',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Editar esta tarea',
'Due Date' => 'Fecha límite',
'Invalid date' => 'Fecha no válida',
- 'Must be done before %B %e, %Y' => 'Debe de estar hecho antes del %e de %B de %Y',
- '%B %e, %Y' => '%e de %B de %Y',
- '%b %e, %Y' => '%e de %B de %Y',
'Automatic actions' => 'Acciones automatizadas',
'Your automatic action have been created successfully.' => 'La acción automatizada ha sido creada correctamente.',
'Unable to create your automatic action.' => 'No se puede crear esta acción automatizada.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Asignar un color a un usuario específico',
'Column title' => 'Título de la columna',
'Position' => 'Posición',
- 'Move Up' => 'Mover hacia arriba',
- 'Move Down' => 'Mover hacia abajo',
'Duplicate to another project' => 'Duplicar a otro proyecto',
'Duplicate' => 'Duplicar',
'link' => 'vinculación',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'El comentario ha sido suprimido correctamente.',
'Unable to remove this comment.' => 'No se puede suprimir este comentario.',
'Do you really want to remove this comment?' => '¿Desea suprimir este comentario?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Sólo los administradores o el autor del comentario tienen acceso a esta página.',
'Current password for the user "%s"' => 'Contraseña actual para el usuario: « %s »',
'The current password is required' => 'La contraseña es obligatoria',
'Wrong password' => 'contraseña incorrecta',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'Falló la autenticación externa',
'Your external account is linked to your profile successfully.' => 'Su cuenta externa se ha vinculado exitosamente con su perfil.',
'Email' => 'Correo',
- 'Link my Google Account' => 'Vincular con mi Cuenta en Google',
- 'Unlink my Google Account' => 'Desvincular de mi Cuenta en Google',
- 'Login with my Google Account' => 'Ingresar con mi Cuenta de Google',
- 'Project not found.' => 'Proyecto no hallado.',
'Task removed successfully.' => 'Tarea suprimida correctamente.',
'Unable to remove this task.' => 'No pude suprimir esta tarea.',
'Remove a task' => 'Borrar una tarea',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Tamaño máximo',
'Unable to upload the file.' => 'No pude cargar el fichero.',
'Display another project' => 'Mostrar otro proyecto',
- 'Login with my Github Account' => 'Ingresar con mi cuenta de Github',
- 'Link my Github Account' => 'Vincular mi cuenta de Github',
- 'Unlink my Github Account' => 'Desvincular mi cuenta de Github',
'Created by %s' => 'Creado por %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Última modificación %e de %B de %Y a las %k:%M %p',
'Tasks Export' => 'Exportar tareas',
'Tasks exportation for "%s"' => 'Exportación de tareas para "%s"',
'Start Date' => 'Fecha de inicio',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Quiero recibir notificaciones sólo de estos proyectos:',
'view the task on Kanboard' => 'ver la tarea en Kanboard',
'Public access' => 'Acceso público',
- 'User management' => 'Gestión de Usuarios',
'Active tasks' => 'Tareas activas',
'Disable public access' => 'Desactivar acceso público',
'Enable public access' => 'Activar acceso público',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Correo electrónico:',
'Notifications:' => 'Notificaciones:',
'Notifications' => 'Notificaciones',
- 'Group:' => 'Grupo:',
- 'Regular user' => 'Usuario regular',
'Account type:' => 'Tipo de Cuenta:',
'Edit profile' => 'Editar perfil',
'Change password' => 'Cambiar contraseña',
'Password modification' => 'Modificacion de contraseña',
'External authentications' => 'Autenticación externa',
- 'Google Account' => 'Cuenta de Google',
- 'Github Account' => 'Cuenta de Github',
'Never connected.' => 'Nunca se ha conectado.',
- 'No account linked.' => 'Sin vínculo con cuenta.',
- 'Account linked.' => 'Vinculada con Cuenta.',
'No external authentication enabled.' => 'Sin autenticación externa activa.',
'Password modified successfully.' => 'Contraseña cambiada correctamente.',
'Unable to change the password.' => 'No pude cambiar la contraseña.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s cambió el concesionario de la tarea %s por %s',
'New password for the user "%s"' => 'Nueva contraseña para el usuario "%s"',
'Choose an event' => 'Seleccione un evento',
- 'Github commit received' => 'Envío a Github recibido',
- 'Github issue opened' => 'Abierto asunto en Github',
- 'Github issue closed' => 'Cerrado asunto en Github',
- 'Github issue reopened' => 'Reabierto asunto en Github',
- 'Github issue assignee change' => 'Cambio en concesionario de asunto de Github',
- 'Github issue label change' => 'Cambio en etiqueta de asunto de Github',
'Create a task from an external provider' => 'Crear una tarea a partir de un proveedor externo',
'Change the assignee based on an external username' => 'Cambiar el concesionario basado en un nombre de usuario externo',
'Change the category based on an external label' => 'Cambiar la categoría basado en una etiqueta externa',
'Reference' => 'Referencia',
- 'Reference: %s' => 'Referencia: %s',
'Label' => 'Etiqueta',
'Database' => 'Base de Datos',
'About' => 'Acerca de',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frecuencia en segundos (60 segundos por defecto)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frecuencia en segundos (0 para deshabilitar esta característica, 10 segundos por defecto)',
'Application URL' => 'URL de la aplicación',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Ejemplo: http://ejemplo.kanboard.net/ (usado por las notificaciones de correo)',
'Token regenerated.' => 'Ficha regenerada.',
'Date format' => 'Formato de la fecha',
'ISO format is always accepted, example: "%s" and "%s"' => 'El formato ISO siempre es aceptado, ejemplo: "%s" y "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Este proyecto es privado',
'Type here to create a new sub-task' => 'Escriba aquí para crear una nueva sub-tarea',
'Add' => 'Añadir',
- 'Estimated time: %s hours' => 'Tiempo estimado: %s horas',
- 'Time spent: %s hours' => 'Tiempo invertido: %s horas',
- 'Started on %B %e, %Y' => 'Iniciado el %e de %B de %Y',
'Start date' => 'Fecha de inicio',
'Time estimated' => 'Tiempo estimado',
'There is nothing assigned to you.' => 'No tiene nada asignado.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Cualquiera tiene acceso a este proyecto',
'Webhooks' => 'Disparadores Web (Webhooks)',
'API' => 'API',
- 'Github webhooks' => 'Disparadores Web (Webhooks) de Github',
- 'Help on Github webhooks' => 'Ayuda con los Disparadores Web (Webhook) de Github',
'Create a comment from an external provider' => 'Crear un comentario a partir de un proveedor externo',
- 'Github issue comment created' => 'Creado el comentario del problema en Github',
'Project management' => 'Administración del proyecto',
'My projects' => 'Mis proyectos',
'Columns' => 'Columnas',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Repartición para "%s"',
'Clone this project' => 'Clonar este proyecto',
'Column removed successfully.' => 'Columna eliminada correctamente',
- 'Github Issue' => 'Problema con Github',
'Not enough data to show the graph.' => 'No hay suficiente información para mostrar el gráfico.',
'Previous' => 'Anterior',
'The id must be an integer' => 'El id debe ser un entero',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Calle por defecto',
'Do you really want to remove this swimlane: "%s"?' => '¿Realmente quiere quitar esta calle: "%s"?',
'Inactive swimlanes' => 'Calles inactivas',
- 'Set project manager' => 'Asignar administrador del proyecto',
- 'Set project member' => 'Asignar miembro del proyecto',
'Remove a swimlane' => 'Quitar un calle',
- 'Rename' => 'Renombrar',
'Show default swimlane' => 'Mostrar calle por defecto',
'Swimlane modification for the project "%s"' => 'Modificación de la calle para el proyecto "%s"',
'Swimlane not found.' => 'Calle no encontrada',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Calles',
'Swimlane updated successfully.' => 'Calle actualizada correctamente',
'The default swimlane have been updated successfully.' => 'La calle por defecto ha sido actualizada correctamente',
- 'Unable to create your swimlane.' => 'Imposible crear su calle',
'Unable to remove this swimlane.' => 'Imposible de quitar esta calle',
'Unable to update this swimlane.' => 'Imposible de actualizar esta calle',
'Your swimlane have been created successfully.' => 'Su calle ha sido creada correctamente',
'Example: "Bug, Feature Request, Improvement"' => 'Ejemplo: "Error, Solicitud de característica, Mejora',
'Default categories for new projects (Comma-separated)' => 'Categorías por defecto para nuevos proyectos (separadas por comas)',
- 'Gitlab commit received' => 'Recibido envío desde Gitlab',
- 'Gitlab issue opened' => 'Abierto asunto de Gitlab',
- 'Gitlab issue closed' => 'Cerrado asunto de Gitlab',
- 'Gitlab webhooks' => 'Disparadores Web (Webhooks) de Gitlab',
- 'Help on Gitlab webhooks' => 'Ayuda sobre Disparadores Web (Webhooks) de Gitlab',
'Integrations' => 'Integraciones',
'Integration with third-party services' => 'Integración con servicios de terceros',
- 'Role for this project' => 'Papel de este proyecto',
- 'Project manager' => 'Administrador de proyecto',
- 'Project member' => 'Miembro de proyecto',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Un administrador de proyecto puede cambiar sus valores y tener más privilegios que un usuario estándar.',
- 'Gitlab Issue' => 'Asunto Gitlab',
'Subtask Id' => 'Id de Subtarea',
'Subtasks' => 'Subtareas',
'Subtasks Export' => 'Exportación de Subtareas',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Ya dispones de una subtarea en progreso',
'Which parts of the project do you want to duplicate?' => '¿Qué partes del proyecto desea duplicar?',
'Disallow login form' => 'Deshabilitar formulario de ingreso',
- 'Bitbucket commit received' => 'Recibido envío desde Bitbucket',
- 'Bitbucket webhooks' => 'Disparadores Web (webhooks) de Bitbucket',
- 'Help on Bitbucket webhooks' => 'Ayuda sobre disparadores web (webhooks) de Bitbucket',
'Start' => 'Inicio',
'End' => 'Fin',
'Task age in days' => 'Edad de la tarea en días',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Esta tarea',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%e de %b',
'Expand tasks' => 'Espande tareas',
'Collapse tasks' => 'Colapsa tareas',
'Expand/collapse tasks' => 'Expande/colapasa tareas',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Atajos de teclado',
'Open board switcher' => 'Abrir conmutador de tablero',
'Application' => 'Aplicación',
- 'since %B %e, %Y at %k:%M %p' => 'desde %e de %B de %Y a las %k:%M %p',
'Compact view' => 'Compactar vista',
'Horizontal scrolling' => 'Desplazamiento horizontal',
'Compact/wide view' => 'Vista compacta/amplia',
'No results match:' => 'No hay resultados coincidentes:',
'Currency' => 'Moneda',
- 'Files' => 'Ficheros',
- 'Images' => 'Imágenes',
'Private project' => 'Proyecto privado',
'AUD - Australian Dollar' => 'AUD - Dólar australiano',
'CAD - Canadian Dollar' => 'CAD - Dólar canadiense',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'El código de autenticación de dos factores es válido',
'Code' => 'Código',
'Two factor authentication' => 'Autenticación de dos factores',
- 'Enable/disable two factor authentication' => 'Activar/desactivar autenticación de dos factores',
'This QR code contains the key URI: ' => 'Este código QR contiene la clave URI: ',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Guarda la clave secreta en su software TOTP (por ejemplo Autenticación de Google o FreeOTP).',
'Check my code' => 'Revisar mi código',
'Secret key: ' => 'Clave secreta: ',
'Test your device' => 'Probar su dispositivo',
'Assign a color when the task is moved to a specific column' => 'Asignar un color al mover la tarea a una columna específica',
'%s via Kanboard' => '%s vía Kanboard',
- 'uploaded by: %s' => 'cargado por: %s',
- 'uploaded on: %s' => 'cargado en: %s',
- 'size: %s' => 'tamaño: %s',
'Burndown chart for "%s"' => 'Trabajo pendiente para "%s"',
'Burndown chart' => 'Trabajo pendiente',
'This chart show the task complexity over the time (Work Remaining).' => 'Este diagrama mestra la complejidad de las tareas a lo largo del tiempo (Trabajo restante)',
@@ -722,7 +645,6 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Capture un patallazo y pulse CTRL+V o ⌘+V para pegar aquí.',
'Screenshot uploaded successfully.' => 'Pantallazo cargado con éxito',
'SEK - Swedish Krona' => 'SEK - Corona sueca',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'El identificador del proyecto us un código opcional alfanumérico que se usa para identificar su proyecto.',
'Identifier' => 'Identificador',
'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 quiere desactuvar la autenticación de dos factores para este usuario: "%s?"',
@@ -731,7 +653,6 @@ return array(
'A task cannot be linked to itself' => 'Una tarea no puede se enlazada con sigo misma',
'The exact same link already exists' => 'El mismo enlace ya existe',
'Recurrent task is scheduled to be generated' => 'Tarea recurrente programada para ser generada',
- 'Recurring information' => 'Información recurrente',
'Score' => 'Puntuación',
'The identifier must be unique' => 'El identificador debe ser único',
'This linked task id doesn\'t exists' => 'El id de tarea no existe',
@@ -777,21 +698,10 @@ return array(
'User that will receive the email' => 'Usuario que recibirá el correo',
'Email subject' => 'Asunto del correo',
'Date' => 'Fecha',
- 'By @%s on Bitbucket' => 'Mediante @%s en Bitbucket',
- 'Bitbucket Issue' => 'Asunto de Bitbucket',
- 'Commit made by @%s on Bitbucket' => 'Envío realizado por @%s en Bitbucket',
- 'Commit made by @%s on Github' => 'Envío realizado por @%s en Github',
- 'By @%s on Github' => 'Por @%s en Github',
- 'Commit made by @%s on Gitlab' => 'Envío realizado por @%s en Gitlab',
'Add a comment log when moving the task between columns' => 'Añadir un comentario al mover la tarea entre columnas',
'Move the task to another column when the category is changed' => 'Mover la tarea a otra columna cuando cambia la categoría',
'Send a task by email to someone' => 'Enviar una tarea a alguien por correo',
'Reopen a task' => 'Reabrir tarea',
- 'Bitbucket issue opened' => 'Abierto asunto de Bitbucket',
- 'Bitbucket issue closed' => 'Cerrado asunto de Bitbucket',
- 'Bitbucket issue reopened' => 'Reabierto asunto de Bitbucket',
- 'Bitbucket issue assignee change' => 'Cambiado concesionario de asunto de Bitbucket',
- 'Bitbucket issue comment created' => 'Creado comentario de asunto de Bitbucket',
'Column change' => 'Cambio de columna',
'Position change' => 'Cambio de posición',
'Swimlane change' => 'Cambio de calle',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'Se ha actualizado el campo "%s"',
'The description have been modified' => 'Se ha modificado la descripción',
'Do you really want to close the task "%s" as well as all subtasks?' => '¿De verdad que quiere cerra la tarea "%s" así como todas las subtareas?',
- 'Swimlane: %s' => 'Calle: %s',
'I want to receive notifications for:' => 'Deseo recibir notificaciones para:',
'All tasks' => 'Todas las tareas',
'Only for tasks assigned to me' => 'Sólo para las tareas que me han sido asignadas',
'Only for tasks created by me' => 'Sólo para las taread creadas por mí',
'Only for tasks created by me and assigned to me' => 'Sólo para las tareas credas por mí y que me han sido asignadas',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%e de %B de %Y a las %k:%M %p',
- 'New due date: %B %e, %Y' => 'Nueva fecha de entrega: %B %e, %Y',
- 'Start date changed: %B %e, %Y' => 'Cambiadad fecha de inicio: %B %e, %Y',
- '%k:%M %p' => '%k:%M %p',
'%%Y-%%m-%%d' => '%%d/%%M/%%Y',
'Total for all columns' => 'Total para todas las columnas',
'You need at least 2 days of data to show the chart.' => 'Necesitas al menos 2 días de datos para mostrar el gráfico.',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'No asignada',
'View advanced search syntax' => 'Ver sintáxis avanzada de búsqueda',
'Overview' => 'Resumen',
- '%b %e %Y' => '%e de %B de %Y',
'Board/Calendar/List view' => 'Vista de Tablero/Calendario/Lista',
'Switch to the board view' => 'Cambiar a vista de tablero',
'Switch to the calendar view' => 'Cambiar a vista de calendario',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Esta gráfica muestra el plazo medio de entrega y de ciclo para las %d últimas tareas transcurridas.',
'Average time into each column' => 'Tiempo medio en cada columna',
'Lead and cycle time' => 'Plazo de entrega y de ciclo',
- 'Google Authentication' => 'Autenticación de Google',
- 'Help on Google authentication' => 'Ayuda con la aAutenticación de Google',
- 'Github Authentication' => 'Autenticación de Github',
- 'Help on Github authentication' => 'Ayuda con la autenticación de Github',
'Lead time: ' => 'Plazo de entrega: ',
'Cycle time: ' => 'Tiempo de Ciclo: ',
'Time spent into each column' => 'Tiempo empleado en cada columna',
@@ -906,18 +805,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Si la tarea no se cierra, se usa la fecha actual en lugar de la de terminación.',
'Set automatically the start date' => 'Poner la fecha de inicio de forma automática',
'Edit Authentication' => 'Editar autenticación',
- 'Google Id' => 'Id de Google',
- 'Github Id' => 'Id de Github',
'Remote user' => 'Usuario remoto',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Los usuarios remotos no almacenan sus contraseñas en la base de datos Kanboard, por ejemplo: cuentas de LDAP, Google y Github',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Si marcas la caja de edición "Desactivar formulario de ingreso", se ignoran las credenciales entradas en el formulario de ingreso.',
- 'By @%s on Gitlab' => 'Por @%s en Gitlab',
- 'Gitlab issue comment created' => 'Creado comentario de asunto de Gitlab',
'New remote user' => 'Nuevo usuario remoto',
'New local user' => 'Nuevo usuario local',
'Default task color' => 'Color por defecto de tarea',
- 'Hide sidebar' => 'Ocultar barra lateral',
- 'Expand sidebar' => 'Expandir barra lateral',
'This feature does not work with all browsers.' => 'Esta característica no funciona con todos los navegadores',
'There is no destination project available.' => 'No está disponible proyecto destino',
'Trigger automatically subtask time tracking' => 'Disparar de forma automática seguimiento temporal de subtarea',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'contribuyentes',
'License:' => 'Licencia:',
'License' => 'Licencia',
- 'Project Administrator' => 'Administrador del Proyecto',
'Enter the text below' => 'Digita el texto de abajo',
'Gantt chart for %s' => 'Diagrama de Gantt para %s',
'Sort by position' => 'Clasificado mediante posición',
@@ -952,11 +844,9 @@ return array(
'open file' => 'abrir fichero',
'End date' => 'Fecha de fin',
'Users overview' => 'Resumen de usuarios',
- 'Managers' => 'Administradores',
'Members' => 'Miembros',
'Shared project' => 'Proyecto compartido',
'Project managers' => 'Administradores de proyecto',
- 'Project members' => 'Miembros de proyecto',
'Gantt chart for all projects' => 'Diagrama de Gantt para todos los proyectos',
'Projects list' => 'Lista de proyectos',
'Gantt chart for this project' => 'Diagrama de Gantt para este proyecto',
@@ -964,26 +854,16 @@ return array(
'End date:' => 'Fecha final',
'There is no start date or end date for this project.' => 'No existe fecha de inicio o de fin para este proyecto.',
'Projects Gantt chart' => 'Diagramas de Gantt de los proyectos',
- 'Start date: %s' => 'Fecha inicial: %s',
- 'End date: %s' => 'Fecha final: %s',
'Link type' => 'Tipo de enlace',
'Change task color when using a specific task link' => 'Cambiar colo de la tarea al usar un enlace específico a tarea',
'Task link creation or modification' => 'Creación o modificación de enlace a tarea',
- 'Login with my Gitlab Account' => 'Ingresar usando mi Cuenta en Gitlab',
'Milestone' => 'Hito',
- 'Gitlab Authentication' => 'Autenticación Gitlab',
- 'Help on Gitlab authentication' => 'Ayuda con autenticación Gitlab',
- 'Gitlab Id' => 'Id de Gitlab',
- 'Gitlab Account' => 'Cuenta de Gitlab',
- 'Link my Gitlab Account' => 'Enlazar con mi Cuenta en Gitlab',
- 'Unlink my Gitlab Account' => 'Desenlazar con mi Cuenta en Gitlab',
'Documentation: %s' => 'Documentación: %s',
'Switch to the Gantt chart view' => 'Conmutar a vista de diagrama de Gantt',
'Reset the search/filter box' => 'Limpiar la caja del filtro de búsqueda',
'Documentation' => 'Documentación',
'Table of contents' => 'Tabla de contenido',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Ayuda con permisos del proyecto',
'Author' => 'Autor',
'Version' => 'Versión',
'Plugins' => 'Plugins',
@@ -1046,7 +926,6 @@ return array(
'Append/Replace' => 'Añadir/Reemplazar',
'Append' => 'Añadir',
'Replace' => 'Reemplazar',
- 'There is no notification method registered.' => 'No hay método de notificación registrado',
'Import' => 'Importar',
'change sorting' => 'Cambiar orden',
'Tasks Importation' => 'Importación de tareas',
@@ -1064,4 +943,209 @@ return array(
'Duplicates are not imported' => 'Los duplicados no son importados',
'Usernames must be lowercase and unique' => 'Los nombres de usuario deben ser únicos y contener sólo minúsculas',
'Passwords will be encrypted if present' => 'Las contraseñas serán cifradas si es que existen',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ 'Project members' => 'Miembros de proyecto',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ 'Estimated vs actual time' => 'Tiempo estimado vs real',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php
index 998684f9..169fa665 100644
--- a/app/Locale/fi_FI/translations.php
+++ b/app/Locale/fi_FI/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Poista projekti',
'Edit the board for "%s"' => 'Muokkaa taulua projektille "%s"',
'All projects' => 'Kaikki projektit',
- 'Change columns' => 'Muokkaa sarakkeita',
'Add a new column' => 'Lisää uusi sarake',
'Title' => 'Nimi',
'Nobody assigned' => 'Ei suorittajaa',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Avaa tehtävä',
'Do you really want to open this task: "%s"?' => 'Haluatko varmasti avata tehtävän: "%s"?',
'Back to the board' => 'Takaisin tauluun',
- 'Created on %B %e, %Y at %k:%M %p' => 'Luotu %d.%m.%Y kello %H:%M',
'There is nobody assigned' => 'Ei suorittajaa',
'Column on the board:' => 'Sarake taululla: ',
- 'Status is open' => 'Status on avoin',
- 'Status is closed' => 'Status on suljettu',
'Close this task' => 'Sulje tämä tehtävä',
'Open this task' => 'Avaa tämä tehtävä',
'There is no description.' => 'Ei kuvausta.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'ID vaaditaan',
'The project id is required' => 'Projektin ID on pakollinen',
'The project name is required' => 'Projektin nimi on pakollinen',
- 'This project must be unique' => 'Projektin nimi täytyy olla uniikki',
'The title is required' => 'Otsikko vaaditaan',
'Settings saved successfully.' => 'Asetukset tallennettu onnistuneesti.',
'Unable to save your settings.' => 'Asetusten tallentaminen epäonnistui.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Työnalla',
'Done' => 'Tehty',
'Application version:' => 'Ohjelman versio:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Valmistunut %d.%m.%Y kello %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d.%m.%Y kello %H:%M',
- 'Date created' => 'Luomispäivä',
- 'Date completed' => 'Valmistumispäivä',
'Id' => 'Id',
'%d closed tasks' => '%d suljettua tehtävää',
'No task for this project' => 'Ei tehtävää tälle projektille',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Monimutkaisuus',
'Task limit' => 'Tehtävien maksimimäärä',
'Task count' => 'Tehtävien määrä',
- 'Edit project access list' => 'Muuta projektin käyttäjiä',
- 'Allow this user' => 'Salli tämä projekti',
- 'Don\'t forget that administrators have access to everything.' => 'Muista että ylläpitäjät pääsevät kaikkialle.',
- 'Revoke' => 'Poista',
- 'List of authorized users' => 'Sallittujen käyttäjien lista',
'User' => 'Käyttäjät',
- // 'Nobody have access to this project.' => '',
'Comments' => 'Kommentit',
'Write your text in Markdown' => 'Kirjoita kommenttisi Markdownilla',
'Leave a comment' => 'Lisää kommentti',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Muokkaa tehtävää',
'Due Date' => 'Deadline',
'Invalid date' => 'Virheellinen päiväys',
- 'Must be done before %B %e, %Y' => 'Täytyy suorittaa ennen %d.%m.%Y',
- '%B %e, %Y' => '%d.%m.%Y',
- // '%b %e, %Y' => '',
'Automatic actions' => 'Automaattiset toiminnot',
'Your automatic action have been created successfully.' => 'Toiminto suoritettiin onnistuneesti.',
'Unable to create your automatic action.' => 'Automaattisen toiminnon luominen epäonnistui.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Valitse väri käyttäjälle',
'Column title' => 'Sarakkeen nimi',
'Position' => 'Positio',
- 'Move Up' => 'Siirrä ylös',
- 'Move Down' => 'Siirrä alas',
'Duplicate to another project' => 'Kopioi toiseen projektiin',
'Duplicate' => 'Monista',
'link' => 'linkki',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Kommentti poistettiin onnistuneesti.',
'Unable to remove this comment.' => 'Kommentin poistaminen epäonnistui.',
'Do you really want to remove this comment?' => 'Haluatko varmasti poistaa tämän kommentin?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Vain ylläpitäjillä tai kommentin jättäjällä on pääsy tälle sivulle.',
'Current password for the user "%s"' => 'Käyttäjän "%s" salasana',
'The current password is required' => 'Salasana vaaditaan',
'Wrong password' => 'Väärä salasana',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'Sähköposti',
- 'Link my Google Account' => 'Linkitä Google-tili',
- 'Unlink my Google Account' => 'Poista Google-tilin linkitys',
- 'Login with my Google Account' => 'Kirjaudu Google tunnuksella',
- 'Project not found.' => 'Projektia ei löytynyt.',
'Task removed successfully.' => 'Tehtävä poistettiin onnistuneesti.',
'Unable to remove this task.' => 'Tehtävän poistaminen epäonnistui.',
'Remove a task' => 'Poista tehtävä',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maksimikoko: ',
'Unable to upload the file.' => 'Tiedoston lataus epäonnistui.',
'Display another project' => 'Näytä toinen projekti',
- 'Login with my Github Account' => 'Kirjaudu sisään Github-tililläni',
- 'Link my Github Account' => 'Liitä Github-tilini',
- 'Unlink my Github Account' => 'Poista liitos Github-tiliini',
'Created by %s' => 'Luonut: %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Viimeksi muokattu %B %e, %Y kello %H:%M',
'Tasks Export' => 'Tehtävien vienti',
'Tasks exportation for "%s"' => 'Tehtävien vienti projektilta "%s"',
'Start Date' => 'Aloituspäivä',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Haluan vastaanottaa ilmoituksia ainoastaan näistä projekteista:',
'view the task on Kanboard' => 'katso tehtävää Kanboardissa',
'Public access' => 'Julkinen käyttöoikeus',
- 'User management' => 'Käyttäjähallinta',
'Active tasks' => 'Aktiiviset tehtävät',
'Disable public access' => 'Poista käytöstä julkinen käyttöoikeus',
'Enable public access' => 'Ota käyttöön ',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Sähköpostiosoite:',
'Notifications:' => 'Ilmoitukset:',
'Notifications' => 'Ilmoitukset',
- 'Group:' => 'Ryhmä:',
- 'Regular user' => 'Peruskäyttäjä',
'Account type:' => 'Tilin tyyppi:',
'Edit profile' => 'Muokkaa profiilia',
'Change password' => 'Vaihda salasana',
'Password modification' => 'Salasanan vaihto',
'External authentications' => 'Muut tunnistautumistavat',
- 'Google Account' => 'Google-tili',
- 'Github Account' => 'Github-tili',
'Never connected.' => 'Ei koskaan liitetty.',
- 'No account linked.' => 'Tiliä ei ole liitetty.',
- 'Account linked.' => 'Tili on liitetty.',
'No external authentication enabled.' => 'Muita tunnistautumistapoja ei ole otettu käyttöön.',
'Password modified successfully.' => 'Salasana vaihdettu onnistuneesti.',
'Unable to change the password.' => 'Salasanan vaihto epäonnistui.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s vaihtoi tehtävän %s saajaksi %s',
'New password for the user "%s"' => 'Uusi salasana käyttäjälle "%s"',
'Choose an event' => 'Valitse toiminta',
- 'Github commit received' => 'Github-kommitti vastaanotettu',
- 'Github issue opened' => 'Github-issue avattu',
- 'Github issue closed' => 'Github-issue suljettu',
- 'Github issue reopened' => 'Github-issue uudelleenavattu',
- 'Github issue assignee change' => 'Github-issuen saajan vaihto',
- 'Github issue label change' => 'Github-issuen labelin vaihto',
'Create a task from an external provider' => 'Luo tehtävä ulkoiselta tarjoajalta',
'Change the assignee based on an external username' => 'Vaihda tehtävän saajaa perustuen ulkoiseen käyttäjänimeen',
'Change the category based on an external label' => 'Vaihda kategoriaa perustuen ulkoiseen labeliin',
'Reference' => 'Viite',
- 'Reference: %s' => 'Viite: %s',
'Label' => 'Label',
'Database' => 'Tietokanta',
'About' => 'Tietoja',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Päivitystiheys sekunteina (60 sekuntia oletuksena)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Päivitystiheys sekunteina (0 poistaa toiminnon käytöstä, oletuksena 10 sekuntia)',
'Application URL' => 'Sovelluksen URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Esimerkiksi: http://example.kanboard.net/ (käytetään sähköposti-ilmoituksissa)',
'Token regenerated.' => 'Token uudelleenluotu.',
'Date format' => 'Päiväyksen muoto',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO-muoto on aina hyväksytty, esimerkiksi %s ja %s',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Tämä projekti on yksityinen',
'Type here to create a new sub-task' => 'Kirjoita tähän luodaksesi uuden alitehtävän',
'Add' => 'Lisää',
- 'Estimated time: %s hours' => 'Arvioitu aika: %s tuntia',
- 'Time spent: %s hours' => 'Aikaa kulunut: %s tuntia',
- 'Started on %B %e, %Y' => 'Aloitettu %B %e, %Y',
'Start date' => 'Aloituspäivä',
'Time estimated' => 'Arvioitu aika',
'There is nothing assigned to you.' => 'Ei tehtäviä, joihin sinut olisi merkitty tekijäksi.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Kaikilla on käyttöoikeus projektiin.',
// 'Webhooks' => '',
// 'API' => '',
- // 'Github webhooks' => '',
- // 'Help on Github webhooks' => '',
// 'Create a comment from an external provider' => '',
- // 'Github issue comment created' => '',
'Project management' => 'Projektin hallinta',
'My projects' => 'Minun projektini',
'Columns' => 'Sarakkeet',
@@ -517,7 +467,6 @@ return array(
// 'User repartition for "%s"' => '',
'Clone this project' => 'Kahdenna projekti',
'Column removed successfully.' => 'Sarake poistettu onnstuneesti.',
- 'Github Issue' => 'Github-issue',
'Not enough data to show the graph.' => 'Ei riittävästi dataa graafin näyttämiseksi.',
'Previous' => 'Edellinen',
'The id must be an integer' => 'ID:n on oltava kokonaisluku',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Oletuskaista',
'Do you really want to remove this swimlane: "%s"?' => 'Haluatko varmasti poistaa tämän kaistan: "%s"?',
'Inactive swimlanes' => 'Passiiviset kaistat',
- // 'Set project manager' => '',
- // 'Set project member' => '',
'Remove a swimlane' => 'Poista kaista',
- 'Rename' => 'Uudelleennimeä',
'Show default swimlane' => 'Näytä oletuskaista',
'Swimlane modification for the project "%s"' => 'Kaistamuutos projektille "%s"',
'Swimlane not found.' => 'Kaistaa ei löydy',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Kaistat',
'Swimlane updated successfully.' => 'Kaista päivitetty onnistuneesti.',
'The default swimlane have been updated successfully.' => 'Oletuskaista päivitetty onnistuneesti.',
- 'Unable to create your swimlane.' => 'Kaistan luonti epäonnistui.',
'Unable to remove this swimlane.' => 'Kaistan poisto epäonnistui.',
'Unable to update this swimlane.' => 'Kaistan päivittäminen epäonnistui.',
'Your swimlane have been created successfully.' => 'Kaista luotu onnistuneesti.',
'Example: "Bug, Feature Request, Improvement"' => 'Esimerkiksi: "Bugit, Ominaisuuspyynnöt, Parannukset"',
'Default categories for new projects (Comma-separated)' => 'Oletuskategoriat uusille projekteille (pilkuin eroteltu)',
- // 'Gitlab commit received' => '',
- // 'Gitlab issue opened' => '',
- // 'Gitlab issue closed' => '',
- // 'Gitlab webhooks' => '',
- // 'Help on 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' => '',
// 'Subtask Id' => '',
// 'Subtasks' => '',
// 'Subtasks Export' => '',
@@ -603,9 +538,6 @@ return array(
// 'You already have one subtask in progress' => '',
// 'Which parts of the project do you want to duplicate?' => '',
// 'Disallow login form' => '',
- // 'Bitbucket commit received' => '',
- // 'Bitbucket webhooks' => '',
- // 'Help on Bitbucket webhooks' => '',
// 'Start' => '',
// 'End' => '',
// 'Task age in days' => '',
@@ -646,7 +578,6 @@ return array(
// 'This task' => '',
// '<1h' => '',
// '%dh' => '',
- // '%b %e' => '',
// 'Expand tasks' => '',
// 'Collapse tasks' => '',
// 'Expand/collapse tasks' => '',
@@ -656,14 +587,11 @@ return array(
// 'Keyboard shortcuts' => '',
// 'Open board switcher' => '',
// 'Application' => '',
- // 'since %B %e, %Y at %k:%M %p' => '',
// 'Compact view' => '',
// 'Horizontal scrolling' => '',
// 'Compact/wide view' => '',
// 'No results match:' => '',
// 'Currency' => '',
- // 'Files' => '',
- // 'Images' => '',
// 'Private project' => '',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
@@ -703,17 +631,12 @@ return array(
// 'The two factor authentication code is valid.' => '',
// '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' => '',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
// 'Burndown chart for "%s"' => '',
// 'Burndown chart' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
// 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
// 'Send a task by email to someone' => '',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
// 'Column change' => '',
// 'Position change' => '',
// 'Swimlane change' => '',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
// 'I want to receive notifications for:' => '',
// 'All tasks' => '',
// 'Only for tasks assigned to me' => '',
// 'Only for tasks created by me' => '',
// 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
// 'Not assigned' => '',
// 'View advanced search syntax' => '',
// 'Overview' => '',
- // '%b %e %Y' => '',
// 'Board/Calendar/List view' => '',
// 'Switch to the board view' => '',
// 'Switch to the calendar view' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
// 'New remote user' => '',
// 'New local user' => '',
// 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
// 'contributors' => '',
// 'License:' => '',
// 'License' => '',
- // 'Project Administrator' => '',
// 'Enter the text below' => '',
// 'Gantt chart for %s' => '',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php
index e39c7664..cbda4aa8 100644
--- a/app/Locale/fr_FR/translations.php
+++ b/app/Locale/fr_FR/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Supprimer le projet',
'Edit the board for "%s"' => 'Modifier le tableau pour « %s »',
'All projects' => 'Tous les projets',
- 'Change columns' => 'Changer les colonnes',
'Add a new column' => 'Ajouter une nouvelle colonne',
'Title' => 'Titre',
'Nobody assigned' => 'Personne assignée',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Ouvrir une tâche',
'Do you really want to open this task: "%s"?' => 'Voulez-vous vraiment ouvrir cette tâche : « %s » ?',
'Back to the board' => 'Retour au tableau',
- 'Created on %B %e, %Y at %k:%M %p' => 'Créé le %d/%m/%Y à %H:%M',
'There is nobody assigned' => 'Il n\'y a personne d\'assigné à cette tâche',
'Column on the board:' => 'Colonne sur le tableau : ',
- 'Status is open' => 'État ouvert',
- 'Status is closed' => 'État fermé',
'Close this task' => 'Fermer cette tâche',
'Open this task' => 'Ouvrir cette tâche',
'There is no description.' => 'Il n\'y a pas de description.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'L\'identifiant est obligatoire',
'The project id is required' => 'L\'identifiant du projet est obligatoire',
'The project name is required' => 'Le nom du projet est obligatoire',
- 'This project must be unique' => 'Le nom du projet doit être unique',
'The title is required' => 'Le titre est obligatoire',
'Settings saved successfully.' => 'Paramètres sauvegardés avec succès.',
'Unable to save your settings.' => 'Impossible de sauvegarder vos réglages.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'En cours',
'Done' => 'Terminé',
'Application version:' => 'Version de l\'application :',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Terminé le %d/%m/%Y à %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d/%m/%Y à %H:%M',
- 'Date created' => 'Date de création',
- 'Date completed' => 'Date de clôture',
'Id' => 'Id.',
'%d closed tasks' => '%d tâches terminées',
'No task for this project' => 'Aucune tâche pour ce projet',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Complexité',
'Task limit' => 'Tâches Max.',
'Task count' => 'Nombre de tâches',
- 'Edit project access list' => 'Modifier l\'accès au projet',
- 'Allow this user' => 'Autoriser cet utilisateur',
- 'Don\'t forget that administrators have access to everything.' => 'N\'oubliez pas que les administrateurs ont accès à tout.',
- 'Revoke' => 'Révoquer',
- 'List of authorized users' => 'Liste des utilisateurs autorisés',
'User' => 'Utilisateur',
- 'Nobody have access to this project.' => 'Personne n\'est autorisé à accéder au projet.',
'Comments' => 'Commentaires',
'Write your text in Markdown' => 'Écrivez votre texte en Markdown',
'Leave a comment' => 'Laissez un commentaire',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Modifier cette tâche',
'Due Date' => 'Date d\'échéance',
'Invalid date' => 'Date invalide',
- 'Must be done before %B %e, %Y' => 'Doit être fait avant le %d/%m/%Y',
- '%B %e, %Y' => '%d %B %Y',
- '%b %e, %Y' => '%d/%m/%Y',
'Automatic actions' => 'Actions automatisées',
'Your automatic action have been created successfully.' => 'Votre action automatisée a été ajoutée avec succès.',
'Unable to create your automatic action.' => 'Impossible de créer votre action automatisée.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Assigner une couleur à un utilisateur',
'Column title' => 'Titre de la colonne',
'Position' => 'Position',
- 'Move Up' => 'Déplacer vers le haut',
- 'Move Down' => 'Déplacer vers le bas',
'Duplicate to another project' => 'Dupliquer dans un autre projet',
'Duplicate' => 'Dupliquer',
'link' => 'lien',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Commentaire supprimé avec succès.',
'Unable to remove this comment.' => 'Impossible de supprimer ce commentaire.',
'Do you really want to remove this comment?' => 'Voulez-vous vraiment supprimer ce commentaire ?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Seuls les administrateurs ou le créateur du commentaire peuvent accéder à cette page.',
'Current password for the user "%s"' => 'Mot de passe actuel pour l\'utilisateur « %s »',
'The current password is required' => 'Le mot de passe actuel est obligatoire',
'Wrong password' => 'Mot de passe invalide',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'L’authentification externe a échoué',
'Your external account is linked to your profile successfully.' => 'Votre compte externe est désormais lié à votre profil.',
'Email' => 'Email',
- 'Link my Google Account' => 'Lier mon compte Google',
- 'Unlink my Google Account' => 'Ne plus utiliser mon compte Google',
- 'Login with my Google Account' => 'Se connecter avec mon compte Google',
- 'Project not found.' => 'Projet introuvable.',
'Task removed successfully.' => 'Tâche supprimée avec succès.',
'Unable to remove this task.' => 'Impossible de supprimer cette tâche.',
'Remove a task' => 'Supprimer une tâche',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Taille maximum : ',
'Unable to upload the file.' => 'Impossible de transférer le fichier.',
'Display another project' => 'Afficher un autre projet',
- 'Login with my Github Account' => 'Se connecter avec mon compte Github',
- 'Link my Github Account' => 'Lier mon compte Github',
- 'Unlink my Github Account' => 'Ne plus utiliser mon compte Github',
'Created by %s' => 'Créé par %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Modifié le %d/%m/%Y à %H:%M',
'Tasks Export' => 'Exportation des tâches',
'Tasks exportation for "%s"' => 'Exportation des tâches pour « %s »',
'Start Date' => 'Date de début',
@@ -376,7 +347,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Je souhaite reçevoir les notifications uniquement pour les projets sélectionnés :',
'view the task on Kanboard' => 'voir la tâche sur Kanboard',
'Public access' => 'Accès public',
- 'User management' => 'Gestion des utilisateurs',
'Active tasks' => 'Tâches actives',
'Disable public access' => 'Désactiver l\'accès public',
'Enable public access' => 'Activer l\'accès public',
@@ -399,18 +369,12 @@ return array(
'Email:' => 'Email :',
'Notifications:' => 'Notifications :',
'Notifications' => 'Notifications',
- 'Group:' => 'Groupe :',
- 'Regular user' => 'Utilisateur normal',
'Account type:' => 'Type de compte :',
'Edit profile' => 'Modifier le profil',
'Change password' => 'Changer le mot de passe',
'Password modification' => 'Changement de mot de passe',
'External authentications' => 'Authentifications externes',
- 'Google Account' => 'Compte Google',
- 'Github Account' => 'Compte Github',
'Never connected.' => 'Jamais connecté.',
- 'No account linked.' => 'Aucun compte attaché.',
- 'Account linked.' => 'Compte attaché.',
'No external authentication enabled.' => 'Aucune authentication externe activée.',
'Password modified successfully.' => 'Mot de passe changé avec succès.',
'Unable to change the password.' => 'Impossible de changer le mot de passe.',
@@ -448,17 +412,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s a changé la personne assignée à la tâche %s pour %s',
'New password for the user "%s"' => 'Nouveau mot de passe pour l\'utilisateur « %s »',
'Choose an event' => 'Choisir un événement',
- 'Github commit received' => 'Commit reçu via Github',
- 'Github issue opened' => 'Ouverture d\'un ticket sur Github',
- 'Github issue closed' => 'Fermeture d\'un ticket sur Github',
- 'Github issue reopened' => 'Réouverture d\'un ticket sur Github',
- 'Github issue assignee change' => 'Changement d\'assigné sur un ticket Github',
- 'Github issue label change' => 'Changement de libellé sur un ticket Github',
'Create a task from an external provider' => 'Créer une tâche depuis un fournisseur externe',
'Change the assignee based on an external username' => 'Changer l\'assigné en fonction d\'un utilisateur externe',
'Change the category based on an external label' => 'Changer la catégorie en fonction d\'un libellé externe',
'Reference' => 'Référence',
- 'Reference: %s' => 'Référence : %s',
'Label' => 'Libellé',
'Database' => 'Base de données',
'About' => 'À propos',
@@ -476,7 +433,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Fréquence en seconde (60 secondes par défaut)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Fréquence en seconde (0 pour désactiver, 10 secondes par défaut)',
'Application URL' => 'URL de l\'application',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Exemple : http://exemple.kanboard.net/ (utilisé pour les notifications)',
'Token regenerated.' => 'Jeton de sécurité regénéré.',
'Date format' => 'Format des dates',
'ISO format is always accepted, example: "%s" and "%s"' => 'Le format ISO est toujours accepté, exemple : « %s » et « %s »',
@@ -484,9 +440,6 @@ return array(
'This project is private' => 'Ce projet est privé',
'Type here to create a new sub-task' => 'Créer une sous-tâche en écrivant le titre ici',
'Add' => 'Ajouter',
- 'Estimated time: %s hours' => 'Temps estimé: %s hours',
- 'Time spent: %s hours' => 'Temps passé : %s heures',
- 'Started on %B %e, %Y' => 'Commençé le %d/%m/%Y',
'Start date' => 'Date de début',
'Time estimated' => 'Temps estimé',
'There is nothing assigned to you.' => 'Il n\'y a rien d\'assigné pour vous.',
@@ -495,13 +448,10 @@ return array(
'Dashboard' => 'Tableau de bord',
'Confirmation' => 'Confirmation',
'Allow everybody to access to this project' => 'Autoriser tout le monde à accéder à ce projet',
- 'Everybody have access to this project.' => 'Tout le monde a acccès à ce projet.',
+ 'Everybody have access to this project.' => 'Tout le monde a accès à ce projet.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Webhook Github',
- 'Help on Github webhooks' => 'Aide sur les webhooks Github',
'Create a comment from an external provider' => 'Créer un commentaire depuis un fournisseur externe',
- 'Github issue comment created' => 'Commentaire créé sur un ticket Github',
'Project management' => 'Gestion des projets',
'My projects' => 'Mes projets',
'Columns' => 'Colonnes',
@@ -519,7 +469,6 @@ return array(
'User repartition for "%s"' => 'Répartition des utilisateurs pour « %s »',
'Clone this project' => 'Cloner ce projet',
'Column removed successfully.' => 'Colonne supprimée avec succès.',
- 'Github Issue' => 'Ticket Github',
'Not enough data to show the graph.' => 'Pas assez de données pour afficher le graphique.',
'Previous' => 'Précédent',
'The id must be an integer' => 'L\'id doit être un entier',
@@ -549,10 +498,7 @@ return array(
'Default swimlane' => 'Swimlane par défaut',
'Do you really want to remove this swimlane: "%s"?' => 'Voulez-vous vraiment supprimer cette swimlane : « %s » ?',
'Inactive swimlanes' => 'Swimlanes inactives',
- 'Set project manager' => 'Mettre chef de projet',
- 'Set project member' => 'Mettre membre du projet',
'Remove a swimlane' => 'Supprimer une swimlane',
- 'Rename' => 'Renommer',
'Show default swimlane' => 'Afficher la swimlane par défaut',
'Swimlane modification for the project "%s"' => 'Modification d\'une swimlane pour le projet « %s »',
'Swimlane not found.' => 'Cette swimlane est introuvable.',
@@ -560,24 +506,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane mise à jour avec succès.',
'The default swimlane have been updated successfully.' => 'La swimlane par défaut a été mise à jour avec succès.',
- 'Unable to create your swimlane.' => 'Impossible de créer votre swimlane.',
'Unable to remove this swimlane.' => 'Impossible de supprimer cette swimlane.',
'Unable to update this swimlane.' => 'Impossible de mettre à jour cette swimlane.',
'Your swimlane have been created successfully.' => 'Votre swimlane a été créée avec succès.',
'Example: "Bug, Feature Request, Improvement"' => 'Exemple: « Incident, Demande de fonctionnalité, Amélioration »',
'Default categories for new projects (Comma-separated)' => 'Catégories par défaut pour les nouveaux projets (séparation par des virgules)',
- 'Gitlab commit received' => 'Commit reçu via Gitlab',
- 'Gitlab issue opened' => 'Ouverture d\'un ticket sur Gitlab',
- 'Gitlab issue closed' => 'Fermeture d\'un ticket sur Gitlab',
- 'Gitlab webhooks' => 'Webhook Gitlab',
- 'Help on Gitlab webhooks' => 'Aide sur les webhooks Gitlab',
'Integrations' => 'Intégrations',
'Integration with third-party services' => 'Intégration avec des services externes',
- 'Role for this project' => 'Rôle pour ce projet',
- 'Project manager' => 'Chef de projet',
- 'Project member' => 'Membre du projet',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Un chef de projet peut changer les paramètres du projet et possède plus de privilèges qu\'un utilisateur standard.',
- 'Gitlab Issue' => 'Ticket Gitlab',
'Subtask Id' => 'Identifiant de la sous-tâche',
'Subtasks' => 'Sous-tâches',
'Subtasks Export' => 'Exportation des sous-tâches',
@@ -605,9 +540,6 @@ return array(
'You already have one subtask in progress' => 'Vous avez déjà une sous-tâche en progrès',
'Which parts of the project do you want to duplicate?' => 'Quelles parties du projet voulez-vous dupliquer ?',
'Disallow login form' => 'Interdire le formulaire d\'authentification',
- 'Bitbucket commit received' => 'Commit reçu via Bitbucket',
- 'Bitbucket webhooks' => 'Webhook Bitbucket',
- 'Help on Bitbucket webhooks' => 'Aide sur les webhooks Bitbucket',
'Start' => 'Début',
'End' => 'Fin',
'Task age in days' => 'Âge de la tâche en jours',
@@ -648,7 +580,6 @@ return array(
'This task' => 'Cette tâche',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%e %b',
'Expand tasks' => 'Déplier les tâches',
'Collapse tasks' => 'Replier les tâches',
'Expand/collapse tasks' => 'Plier/déplier les tâches',
@@ -658,14 +589,11 @@ return array(
'Keyboard shortcuts' => 'Raccourcis clavier',
'Open board switcher' => 'Ouvrir le sélecteur de tableau',
'Application' => 'Application',
- 'since %B %e, %Y at %k:%M %p' => 'depuis le %d/%m/%Y à %H:%M',
'Compact view' => 'Vue compacte',
'Horizontal scrolling' => 'Défilement horizontal',
'Compact/wide view' => 'Basculer entre la vue compacte et étendue',
'No results match:' => 'Aucun résultat :',
'Currency' => 'Devise',
- 'Files' => 'Fichiers',
- 'Images' => 'Images',
'Private project' => 'Projet privé',
'AUD - Australian Dollar' => 'AUD - Dollar australien',
'CAD - Canadian Dollar' => 'CAD - Dollar canadien',
@@ -705,17 +633,12 @@ return array(
'The two factor authentication code is valid.' => 'Le code pour l\'authentification à deux-facteurs est valide.',
'Code' => 'Code',
'Two factor authentication' => 'Authentification à deux-facteurs',
- 'Enable/disable two factor authentication' => 'Activer/désactiver l\'authentification à deux-facteurs',
'This QR code contains the key URI: ' => 'Ce code QR contient l\'url de la clé : ',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Sauvegardez cette clé secrète dans votre logiciel TOTP (par exemple Google Authenticator ou FreeOTP).',
'Check my code' => 'Vérifier mon code',
'Secret key: ' => 'Clé secrète : ',
'Test your device' => 'Testez votre appareil',
'Assign a color when the task is moved to a specific column' => 'Assigner une couleur lorsque la tâche est déplacée dans une colonne spécifique',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'Téléchargé par %s',
- 'uploaded on: %s' => 'Téléchargé le %s',
- 'size: %s' => 'Taille : %s',
'Burndown chart for "%s"' => 'Graphique d\'avancement pour « %s »',
'Burndown chart' => 'Graphique d\'avancement',
'This chart show the task complexity over the time (Work Remaining).' => 'Ce graphique représente la complexité des tâches en fonction du temps (travail restant).',
@@ -724,20 +647,18 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Prenez une capture d\'écran et appuyez sur CTRL+V ou ⌘+V pour coller ici.',
'Screenshot uploaded successfully.' => 'Capture d\'écran téléchargée avec succès.',
'SEK - Swedish Krona' => 'SEK - Couronne suédoise',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'L\'identificateur du projet est un code alpha-numérique optionnel pour identifier votre projet.',
'Identifier' => 'Identificateur',
'Disable two factor authentication' => 'Désactiver l\'authentification à deux facteurs',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Voulez-vous vraiment désactiver l\'authentification à deux facteurs pour cet utilisateur : « %s » ?',
'Edit link' => 'Modifier un lien',
- 'Start to type task title...' => 'Entrez le titre de la tâche…',
+ 'Start to type task title...' => 'Entrez le titre de la tâche...',
'A task cannot be linked to itself' => 'Une tâche ne peut être liée à elle-même',
'The exact same link already exists' => 'Un lien identique existe déjà',
'Recurrent task is scheduled to be generated' => 'La tâche récurrente est programmée pour être créée',
- 'Recurring information' => 'Information sur la récurrence',
'Score' => 'Complexité',
'The identifier must be unique' => 'L\'identifiant doit être unique',
'This linked task id doesn\'t exists' => 'L\'identifiant de la task liée n\'existe pas',
- 'This value must be alphanumeric' => 'Cette valeur doit être alpha-numérique',
+ 'This value must be alphanumeric' => 'Cette valeur doit être alphanumérique',
'Edit recurrence' => 'Modifier la récurrence',
'Generate recurrent task' => 'Générer une tâche récurrente',
'Trigger to generate recurrent task' => 'Déclencheur pour générer la tâche récurrente',
@@ -779,21 +700,10 @@ return array(
'User that will receive the email' => 'Utilisateur qui va reçevoir l\'email',
'Email subject' => 'Sujet de l\'email',
'Date' => 'Date',
- 'By @%s on Bitbucket' => 'Par @%s sur Bitbucket',
- 'Bitbucket Issue' => 'Ticket Bitbucket',
- 'Commit made by @%s on Bitbucket' => 'Commit fait par @%s sur Bitbucket',
- 'Commit made by @%s on Github' => 'Commit fait par @%s sur Github',
- 'By @%s on Github' => 'Par @%s sur Github',
- 'Commit made by @%s on Gitlab' => 'Commit fait par @%s sur Gitlab',
'Add a comment log when moving the task between columns' => 'Ajouter un commentaire d\'information lorsque une tâche est déplacée dans une autre colonne',
'Move the task to another column when the category is changed' => 'Déplacer une tâche vers une autre colonne lorsque la catégorie a changé',
'Send a task by email to someone' => 'Envoyer une tâche par email à quelqu\'un',
'Reopen a task' => 'Rouvrir une tâche',
- 'Bitbucket issue opened' => 'Ticket Bitbucket ouvert',
- 'Bitbucket issue closed' => 'Ticket Bitbucket fermé',
- 'Bitbucket issue reopened' => 'Ticket Bitbucket rouvert',
- 'Bitbucket issue assignee change' => 'Changement d\'assigné sur un ticket Bitbucket',
- 'Bitbucket issue comment created' => 'Commentaire créé sur un ticket Bitbucket',
'Column change' => 'Changement de colonne',
'Position change' => 'Changement de position',
'Swimlane change' => 'Changement de swimlane',
@@ -828,17 +738,11 @@ return array(
'The field "%s" have been updated' => 'Le champ « %s » a été mis à jour',
'The description have been modified' => 'La description a été modifiée',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Voulez-vous vraiment fermer la tâche « %s » ainsi que toutes ses sous-tâches ?',
- 'Swimlane: %s' => 'Swimlane : %s',
'I want to receive notifications for:' => 'Je veux reçevoir les notifications pour :',
'All tasks' => 'Toutes les Tâches',
'Only for tasks assigned to me' => 'Seulement les tâches qui me sont assignées',
'Only for tasks created by me' => 'Seulement les tâches que j\'ai créées',
'Only for tasks created by me and assigned to me' => 'Seulement les tâches créées par moi-même et celles qui me sont assignées',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%d/%m/%Y %H:%M',
- 'New due date: %B %e, %Y' => 'Nouvelle date d\'échéance : %d/%m/%Y',
- 'Start date changed: %B %e, %Y' => 'Date de début modifiée : %d/%m/%Y',
- '%k:%M %p' => '%H:%M',
'%%Y-%%m-%%d' => '%%d/%%m/%%Y',
'Total for all columns' => 'Total pour toutes les colonnes',
'You need at least 2 days of data to show the chart.' => 'Vous avez besoin d\'au minimum 2 jours de données pour afficher le graphique.',
@@ -863,7 +767,6 @@ return array(
'Not assigned' => 'Non assignées',
'View advanced search syntax' => 'Voir la syntaxe pour la recherche avancée',
'Overview' => 'Vue d\'ensemble',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Vue Tableau/Calendrier/Liste',
'Switch to the board view' => 'Basculer vers le tableau',
'Switch to the calendar view' => 'Basculer vers le calendrier',
@@ -896,10 +799,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Ce graphique montre la durée moyenne du lead et cycle time pour les %d dernières tâches.',
'Average time into each column' => 'Temps moyen dans chaque colonne',
'Lead and cycle time' => 'Lead et cycle time',
- 'Google Authentication' => 'Authentification Google',
- 'Help on Google authentication' => 'Aide sur l\'authentification Google',
- 'Github Authentication' => 'Authentification Github',
- 'Help on Github authentication' => 'Aide sur l\'authentification Github',
'Lead time: ' => 'Lead time : ',
'Cycle time: ' => 'Temps de cycle : ',
'Time spent into each column' => 'Temps passé dans chaque colonne',
@@ -908,18 +807,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Si la tâche n\'est pas fermée, l\'heure courante est utilisée à la place de la date de complétion.',
'Set automatically the start date' => 'Définir automatiquement la date de début',
'Edit Authentication' => 'Modifier l\'authentification',
- 'Google Id' => 'Identifiant Google',
- 'Github Id' => 'Identifiant Github',
'Remote user' => 'Utilisateur distant',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Les utilisateurs distants ne stockent pas leur mot de passe dans la base de données de Kanboard, exemples : comptes LDAP, Github ou Google.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Si vous cochez la case « Interdire le formulaire d\'authentification », les identifiants entrés dans le formulaire d\'authentification seront ignorés.',
- 'By @%s on Gitlab' => 'Par @%s sur Gitlab',
- 'Gitlab issue comment created' => 'Commentaire créé sur un ticket Gitlab',
'New remote user' => 'Créer un utilisateur distant',
'New local user' => 'Créer un utilisateur local',
'Default task color' => 'Couleur par défaut des tâches',
- 'Hide sidebar' => 'Cacher la barre latérale',
- 'Expand sidebar' => 'Déplier la barre latérale',
'This feature does not work with all browsers.' => 'Cette fonctionnalité n\'est pas compatible avec tous les navigateurs',
'There is no destination project available.' => 'Il n\'y a pas de projet de destination disponible.',
'Trigger automatically subtask time tracking' => 'Déclencher automatiquement le suivi du temps pour les sous-tâches',
@@ -934,7 +827,6 @@ return array(
'contributors' => 'contributeurs',
'License:' => 'Licence :',
'License' => 'Licence',
- 'Project Administrator' => 'Administrateur de projet',
'Enter the text below' => 'Entrez le texte ci-dessous',
'Gantt chart for %s' => 'Diagramme de Gantt pour %s',
'Sort by position' => 'Trier par position',
@@ -954,11 +846,9 @@ return array(
'open file' => 'ouvrir le fichier',
'End date' => 'Date de fin',
'Users overview' => 'Vue d\'ensemble des utilisateurs',
- 'Managers' => 'Gérants',
'Members' => 'Membres',
'Shared project' => 'Projet partagé',
'Project managers' => 'Gestionnaires de projet',
- 'Project members' => 'Membres de projet',
'Gantt chart for all projects' => 'Diagramme de Gantt pour tous les projets',
'Projects list' => 'Liste des projets',
'Gantt chart for this project' => 'Diagramme de Gantt pour ce projet',
@@ -966,26 +856,16 @@ return array(
'End date:' => 'Date de fin :',
'There is no start date or end date for this project.' => 'Il n\'y a pas de date de début ou de date de fin pour ce projet.',
'Projects Gantt chart' => 'Diagramme de Gantt des projets',
- 'Start date: %s' => 'Date de début : %s',
- 'End date: %s' => 'Date de fin : %s',
'Link type' => 'Type de lien',
'Change task color when using a specific task link' => 'Changer la couleur de la tâche lorsqu\'un lien spécifique est utilisé',
'Task link creation or modification' => 'Création ou modification d\'un lien sur une tâche',
- 'Login with my Gitlab Account' => 'Se connecter avec mon compte Gitlab',
'Milestone' => 'Étape importante',
- 'Gitlab Authentication' => 'Authentification Gitlab',
- 'Help on Gitlab authentication' => 'Aide sur l\'authentification Gitlab',
- 'Gitlab Id' => 'Identifiant Gitlab',
- 'Gitlab Account' => 'Compte Gitlab',
- 'Link my Gitlab Account' => 'Lier mon compte Gitlab',
- 'Unlink my Gitlab Account' => 'Ne plus utiliser mon compte Gitlab',
'Documentation: %s' => 'Documentation : %s',
'Switch to the Gantt chart view' => 'Passer à la vue en diagramme de Gantt',
'Reset the search/filter box' => 'Réinitialiser le champ de recherche',
'Documentation' => 'Documentation',
'Table of contents' => 'Table des matières',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Aide avec les permissions des projets',
'Author' => 'Auteur',
'Version' => 'Version',
'Plugins' => 'Extensions',
@@ -1048,7 +928,6 @@ return array(
'Append/Replace' => 'Ajouter/Remplaçer',
'Append' => 'Ajouter',
'Replace' => 'Remplaçer',
- 'There is no notification method registered.' => 'Il n\'y a aucune méthode de notification enregistrée.',
'Import' => 'Importation',
'change sorting' => 'changer l\'ordre',
'Tasks Importation' => 'Importation des tâches',
@@ -1066,4 +945,210 @@ return array(
'Duplicates are not imported' => 'Les doublons ne sont pas importés',
'Usernames must be lowercase and unique' => 'Les noms d\'utilisateurs doivent être en minuscule et unique',
'Passwords will be encrypted if present' => 'Les mots de passe seront chiffrés si présent',
+ '%s attached a new file to the task %s' => '%s a attaché un nouveau fichier à la tâche %s',
+ 'Link type' => 'Type de lien',
+ 'Assign automatically a category based on a link' => 'Assigner automatiquement une catégorie en fonction d\'un lien',
+ 'BAM - Konvertible Mark' => 'BAM - Mark convertible',
+ 'Assignee Username' => 'Utilisateur assigné',
+ 'Assignee Name' => 'Nom de l\'assigné',
+ 'Groups' => 'Groupes',
+ 'Members of %s' => 'Membres de %s',
+ 'New group' => 'Nouveau groupe',
+ 'Group created successfully.' => 'Groupe créé avec succès.',
+ 'Unable to create your group.' => 'Impossible de créé votre groupe.',
+ 'Edit group' => 'Modifier le groupe',
+ 'Group updated successfully.' => 'Groupe mis à jour avec succès.',
+ 'Unable to update your group.' => 'Impossible de mettre à jour votre groupe.',
+ 'Add group member to "%s"' => 'Ajouter un membre au groupe « %s »',
+ 'Group member added successfully.' => 'Membre ajouté avec succès au groupe.',
+ 'Unable to add group member.' => 'Impossible d\'ajouter ce membre au groupe.',
+ 'Remove user from group "%s"' => 'Supprimer un utilisateur d\'un groupe « %s »',
+ 'User removed successfully from this group.' => 'Utilisateur supprimé avec succès de ce groupe.',
+ 'Unable to remove this user from the group.' => 'Impossible de supprimer cet utilisateur du groupe.',
+ 'Remove group' => 'Supprimer le groupe',
+ 'Group removed successfully.' => 'Groupe supprimé avec succès.',
+ 'Unable to remove this group.' => 'Impossible de supprimer ce groupe.',
+ 'Project Permissions' => 'Permissions du projet',
+ 'Manager' => 'Gestionnaire',
+ 'Project Manager' => 'Chef de projet',
+ 'Project Member' => 'Membre du projet',
+ 'Project Viewer' => 'Visualiseur de projet',
+ 'Your account is locked for %d minutes' => 'Votre compte est vérouillé pour %d minutes',
+ 'Invalid captcha' => 'Captcha invalid',
+ 'The name must be unique' => 'Le nom doit être unique',
+ 'View all groups' => 'Voir tous les groupes',
+ 'View group members' => 'Voir les membres du groupe',
+ 'There is no user available.' => 'Il n\'y a aucun utilisateur disponible',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Voulez-vous vraiment supprimer l\'utilisateur « %s » du groupe « %s » ?',
+ 'There is no group.' => 'Il n\'y a aucun groupe.',
+ 'External Id' => 'Identifiant externe',
+ 'Add group member' => 'Ajouter un membre au groupe',
+ 'Do you really want to remove this group: "%s"?' => 'Voulez-vous vraiment supprimer ce groupe : « %s » ?',
+ 'There is no user in this group.' => 'Il n\'y a aucun utilisateur dans ce groupe',
+ 'Remove this user' => 'Supprimer cet utilisateur',
+ 'Permissions' => 'Permissions',
+ 'Allowed Users' => 'Utilisateurs autorisés',
+ 'No user have been allowed specifically.' => 'Aucun utilisateur a été autorisé spécifiquement.',
+ 'Role' => 'Rôle',
+ 'Enter user name...' => 'Entrez le nom de l\'utilisateur...',
+ 'Allowed Groups' => 'Groupes autorisés',
+ 'No group have been allowed specifically.' => 'Aucun groupe a été autorisé spécifiquement.',
+ 'Group' => 'Groupe',
+ 'Group Name' => 'Nom du groupe',
+ 'Enter group name...' => 'Entrez le nom du groupe...',
+ 'Role:' => 'Rôle :',
+ 'Project members' => 'Membres du projet',
+ 'Compare hours for "%s"' => 'Comparer les heures pour « %s »',
+ '%s mentioned you in the task #%d' => '%s vous a mentionné dans la tâche n°%d',
+ '%s mentioned you in a comment on the task #%d' => '%s vous a mentionné dans un commentaire de la tâche n°%d',
+ 'You were mentioned in the task #%d' => 'Vous avez été mentionné dans la tâche n°%d',
+ 'You were mentioned in a comment on the task #%d' => 'Vous avez été mentionné dans un commentaire de la tâche n°%d',
+ 'Mentioned' => 'Mentionné',
+ 'Compare Estimated Time vs Actual Time' => 'Comparer le temps estimé et le temps actuel',
+ 'Estimated hours: ' => 'Heures estimées : ',
+ 'Actual hours: ' => 'Heures actuelles : ',
+ 'Hours Spent' => 'Heures passées',
+ 'Hours Estimated' => 'Heures estimées',
+ 'Estimated Time' => 'Temps estimé',
+ 'Actual Time' => 'Temps actuel',
+ 'Estimated vs actual time' => 'Temps estimé vs actuel',
+ 'RUB - Russian Ruble' => 'RUB - Rouble russe',
+ 'Assign the task to the person who does the action when the column is changed' => 'Assigner la tâche à la personne qui fait l\'action lorsque la colonne est changée',
+ 'Close a task in a specific column' => 'Fermer une tâche dans une colonne specifique',
+ 'Time-based One-time Password Algorithm' => 'Mot de passe à usage unique basé sur le temps',
+ 'Two-Factor Provider: ' => 'Fournisseur d\'authentification à deux facteurs : ',
+ 'Disable two-factor authentication' => 'Désactiver l\'authentification à deux-facteurs',
+ 'Enable two-factor authentication' => 'Activer l\'authentification à deux-facteurs',
+ 'There is no integration registered at the moment.' => 'Il n\'y a aucune intégration enregistrée pour le moment.',
+ 'Password Reset for Kanboard' => 'Réinitialisation du mot de passe pour Kanboard',
+ 'Forgot password?' => 'Mot de passe oublié ?',
+ 'Enable "Forget Password"' => 'Activer la fonctionnalité « Mot de passe oublié »',
+ 'Password Reset' => 'Réinitialisation du mot de passe',
+ 'New password' => 'Nouveau mot de passe',
+ 'Change Password' => 'Changer de mot de passe',
+ 'To reset your password click on this link:' => 'Pour réinitialiser votre mot de passe cliquer sur ce lien :',
+ 'Last Password Reset' => 'Dernières réinitialisation de mot de passe',
+ 'The password has never been reinitialized.' => 'Le mot de passe n\'a jamais été réinitialisé.',
+ 'Creation' => 'Création',
+ 'Expiration' => 'Expiration',
+ 'Password reset history' => 'Historique de la réinitialisation du mot de passe',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Toutes les tâches de la colonne « %s » et de la swimlane « %s » ont été fermées avec succès.',
+ 'Do you really want to close all tasks of this column?' => 'Voulez-vous vraiment fermer toutes les tâches de cette colonne ?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d tâche(s) dans la colonne « %s » et la swimlane « %s » seront fermées.',
+ 'Close all tasks of this column' => 'Fermer toutes les tâches de cette colonne',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Aucun plugin n\'a enregistré une méthode de notification de projet. Vous pouvez toujours configurer les notifications individuelles dans votre profil d\'utilisateur.',
+ 'My dashboard' => 'Mon tableau de bord',
+ 'My profile' => 'Mon profile',
+ 'Project owner: ' => 'Responsable du projet : ',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'L\'identifiant du projet est optionnel et doit être alphanumérique, example: MONPROJET.',
+ 'Project owner' => 'Responsable du projet',
+ 'Those dates are useful for the project Gantt chart.' => 'Ces dates sont utiles pour le diagramme de Gantt des projets.',
+ 'Private projects do not have users and groups management.' => 'Les projets privés n\'ont pas de gestion d\'utilisateurs et de groupes.',
+ 'There is no project member.' => 'Il y a aucun membre du projet.',
+ 'Priority' => 'Priorité',
+ 'Task priority' => 'Priorité des tâches',
+ 'General' => 'Général',
+ 'Dates' => 'Dates',
+ 'Default priority' => 'Priorité par défaut',
+ 'Lowest priority' => 'Priorité basse',
+ 'Highest priority' => 'Priorité haute',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'Si vous mettez zéro pour la priorité basse et haute, cette fonctionnalité sera désactivée.',
+ 'Close a task when there is no activity' => 'Fermer une tâche sans activité',
+ 'Duration in days' => 'Durée en jours',
+ 'Send email when there is no activity on a task' => 'Envoyer un email lorsqu\'il n\'y a pas d\'activité sur une tâche',
+ 'List of external links' => 'Liste des liens externes',
+ 'Unable to fetch link information.' => 'Impossible de récupérer les informations sur le lien.',
+ 'Daily background job for tasks' => 'Tâche planifée quotidienne pour les tâches',
+ 'Auto' => 'Auto',
+ 'Related' => 'Relié',
+ 'Attachment' => 'Pièce-jointe',
+ 'Title not found' => 'Titre non trouvé',
+ 'Web Link' => 'Lien web',
+ 'External links' => 'Liens externes',
+ 'Add external link' => 'Ajouter un lien externe',
+ 'Type' => 'Type',
+ 'Dependency' => 'Dépendance',
+ 'Add internal link' => 'Ajouter un lien interne',
+ 'Add a new external link' => 'Ajouter un nouveau lien externe',
+ 'Edit external link' => 'Modifier un lien externe',
+ 'External link' => 'Lien externe',
+ 'Copy and paste your link here...' => 'Copier-coller vôtre lien ici...',
+ 'URL' => 'URL',
+ 'There is no external link for the moment.' => 'Il n\'y a pas de lien externe pour le moment.',
+ 'Internal links' => 'Liens internes',
+ 'There is no internal link for the moment.' => 'Il n\'y a pas de lien interne pour le moment.',
+ 'Assign to me' => 'Assigner à moi',
+ 'Me' => 'Moi',
+ 'Do not duplicate anything' => 'Ne rien dupliquer',
+ 'Projects management' => 'Gestion des projets',
+ 'Users management' => 'Gestion des utilisateurs',
+ 'Groups management' => 'Gestion des groupes',
+ 'Create from another project' => 'Créer depuis un autre projet',
+ 'There is no subtask at the moment.' => 'Il n\'y a aucune sous-tâche pour le moment.',
+ 'open' => 'ouvert',
+ 'closed' => 'fermé',
+ 'Priority:' => 'Priorité :',
+ 'Reference:' => 'Référence :',
+ 'Complexity:' => 'Complexité :',
+ 'Swimlane:' => 'Swimlane :',
+ 'Column:' => 'Colonne :',
+ 'Position:' => 'Position :',
+ 'Creator:' => 'Créateur :',
+ 'Time estimated:' => 'Temps estimé :',
+ '%s hours' => '%s heures',
+ 'Time spent:' => 'Temps passé :',
+ 'Created:' => 'Créé le :',
+ 'Modified:' => 'Modifié le :',
+ 'Completed:' => 'Terminé le :',
+ 'Started:' => 'Commençé le :',
+ 'Moved:' => 'Déplacé le : ',
+ 'Task #%d' => 'Tâche n°%d',
+ 'Sub-tasks' => 'Sous-tâches',
+ 'Date and time format' => 'Format de la date et de l\'heure',
+ 'Time format' => 'Format de l\'heure',
+ 'Start date: ' => 'Date de début : ',
+ 'End date: ' => 'Date de fin : ',
+ 'New due date: ' => 'Nouvelle date d\'échéance : ',
+ 'Start date changed: ' => 'Date de début modifiée : ',
+ 'Disable private projects' => 'Désactiver les projets privés',
+ 'Do you really want to remove this custom filter: "%s"?' => 'Voulez-vous vraiment supprimer ce filtre personnalisé : « %s » ?',
+ 'Remove a custom filter' => 'Supprimer un filtre personnalisé',
+ 'User activated successfully.' => 'Utilisateur activé avec succès.',
+ 'Unable to enable this user.' => 'Impossible d\'activer cet utilisateur.',
+ 'User disabled successfully.' => 'Utilisateur désactivé avec succès.',
+ 'Unable to disable this user.' => 'Impossible de désactiver cet utilisateur.',
+ 'All files have been uploaded successfully.' => 'Tous les fichiers ont été uploadés avec succès.',
+ 'View uploaded files' => 'Voir les fichiers uploadés',
+ 'The maximum allowed file size is %sB.' => 'La taille maximale autorisée pour les fichiers est de %so.',
+ 'Choose files again' => 'Choisir de nouveau des fichiers',
+ 'Drag and drop your files here' => 'Glissez-déposez vos fichiers ici',
+ 'choose files' => 'choisissez des fichiers',
+ 'View profile' => 'Voir le profile',
+ 'Two Factor' => 'Deux-Facteurs',
+ 'Disable user' => 'Désactiver l\'utilisateur',
+ 'Do you really want to disable this user: "%s"?' => 'Voulez-vous vraiment désactiver cet utilisateur : « %s » ?',
+ 'Enable user' => 'Activer un utilisateur',
+ 'Do you really want to enable this user: "%s"?' => 'Voulez-vous vraiment activer cet utilisateur : « %s » ?',
+ 'Download' => 'Télécharger',
+ 'Uploaded: %s' => 'Uploadé : %s',
+ 'Size: %s' => 'Taille : %s',
+ 'Uploaded by %s' => 'Uploadé par %s',
+ 'Filename' => 'Nom du fichier',
+ 'Size' => 'Taille',
+ 'Column created successfully.' => 'La colonne a été créée avec succès.',
+ 'Another column with the same name exists in the project' => 'Une autre colonne existe avec le même nom dans le projet',
+ 'Default filters' => 'Filtres par défaut',
+ 'Your board doesn\'t have any column!' => 'Votre tableau n\'a aucune colonne',
+ 'Change column position' => 'Changer la position de la colonne',
+ 'Switch to the project overview' => 'Aller à l\'aperçu du projet',
+ 'User filters' => 'Filtres des utilisateurs',
+ 'Category filters' => 'Filtres des catégories',
+ 'Upload a file' => 'Uploader un fichier',
+ 'There is no attachment at the moment.' => 'Il n\'y a aucune pièce-jointe pour le moment.',
+ 'View file' => 'Voir le fichier',
+ 'Last activity' => 'Dernières activités',
+ 'Change subtask position' => 'Changer la position de la sous-tâche',
+ 'This value must be greater than %d' => 'Cette valeur doit être plus grande que %d',
+ 'Another swimlane with the same name exists in the project' => 'Une autre swimlane existe avec le même nom dans le projet',
+ 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => 'Exemple : http://exemple.kanboard.net/ (utilisé pour générer les URLs absolues)',
);
diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php
index 59b1d569..71d068e0 100644
--- a/app/Locale/hu_HU/translations.php
+++ b/app/Locale/hu_HU/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Projekt törlése',
'Edit the board for "%s"' => 'Tábla szerkesztése: "%s"',
'All projects' => 'Minden projekt',
- 'Change columns' => 'Oszlop módosítása',
'Add a new column' => 'Új oszlop',
'Title' => 'Cím',
'Nobody assigned' => 'Nincs felelős',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Feladat felnyitás',
'Do you really want to open this task: "%s"?' => 'Tényleg meg akarja nyitni ezt a feladatot: "%s"?',
'Back to the board' => 'Vissza a táblához',
- 'Created on %B %e, %Y at %k:%M %p' => 'Létrehozva: %Y. %m. %d. %H:%M',
'There is nobody assigned' => 'Nincs felelős',
'Column on the board:' => 'Tábla oszlopa: ',
- 'Status is open' => 'Nyitott állapot',
- 'Status is closed' => 'Zárt állapot',
'Close this task' => 'Feladat lezárása',
'Open this task' => 'Feladat felnyitása',
'There is no description.' => 'Nincs elérhető leírás.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Az ID-t (azonosítót) meg kell adni',
'The project id is required' => 'A projekt ID-t (azonosítót) meg kell adni',
'The project name is required' => 'A projekt nevét meg kell adni',
- 'This project must be unique' => 'A projekt nevének egyedinek kell lennie',
'The title is required' => 'A címet meg kell adni',
'Settings saved successfully.' => 'A beállítások sikeresen mentve.',
'Unable to save your settings.' => 'A beállítások mentése sikertelen.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Folyamatban',
'Done' => 'Kész',
'Application version:' => 'Alkalmazás verzió:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Elkészült: %Y. %m. %d. %H:%M',
- '%B %e, %Y at %k:%M %p' => '%Y. %m. %d. %H:%M',
- 'Date created' => 'Létrehozás időpontja',
- 'Date completed' => 'Befejezés időpontja',
'Id' => 'ID',
'%d closed tasks' => '%d lezárt feladat',
'No task for this project' => 'Nincs feladat ebben a projektben',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Bonyolultság',
'Task limit' => 'Maximális számú feladat',
'Task count' => 'Feladatok száma',
- 'Edit project access list' => 'Projekt hozzáférés módosítása',
- 'Allow this user' => 'Engedélyezi ezt a felhasználót',
- 'Don\'t forget that administrators have access to everything.' => 'Ne felejtsük el: a rendszergazdák mindenhez hozzáférnek.',
- 'Revoke' => 'Visszavon',
- 'List of authorized users' => 'Az engedélyezett felhasználók',
'User' => 'Felhasználó',
- 'Nobody have access to this project.' => 'Senkinek sincs hozzáférése a projekthez.',
'Comments' => 'Hozzászólások',
'Write your text in Markdown' => 'Írja be a szöveget Markdown szintaxissal',
'Leave a comment' => 'Írjon hozzászólást ...',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Feladat módosítása',
'Due Date' => 'Határidő',
'Invalid date' => 'Érvénytelen dátum',
- 'Must be done before %B %e, %Y' => 'Kész kell lennie %Y. %m. %d. előtt',
- '%B %e, %Y' => '%Y. %m. %d.',
- '%b %e, %Y' => '%Y. %m. %d.',
'Automatic actions' => 'Automatikus intézkedések',
'Your automatic action have been created successfully.' => 'Az automatikus intézkedés sikeresen elkészült.',
'Unable to create your automatic action.' => 'Automatikus intézkedés létrehozása nem lehetséges.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Szín hozzárendelése a felhasználóhoz',
'Column title' => 'Oszlopfejléc',
'Position' => 'Pozíció',
- 'Move Up' => 'Fel',
- 'Move Down' => 'Le',
'Duplicate to another project' => 'Másolás másik projektbe',
'Duplicate' => 'Másolás',
'link' => 'link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Megjegyzés sikeresen törölve.',
'Unable to remove this comment.' => 'Megjegyzés törölése nem lehetséges.',
'Do you really want to remove this comment?' => 'Valóban törölni szeretné ezt a megjegyzést?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Csak a rendszergazdák és a megjegyzés létrehozója férhet hozzá az oldalhoz.',
'Current password for the user "%s"' => 'Felhasználó jelenlegi jelszava: "%s"',
'The current password is required' => 'A jelenlegi jelszót meg kell adni',
'Wrong password' => 'Hibás jelszó',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'E-mail',
- 'Link my Google Account' => 'Kapcsold össze a Google fiókkal',
- 'Unlink my Google Account' => 'Válaszd le a Google fiókomat',
- 'Login with my Google Account' => 'Jelentkezzen be Google fiókkal',
- 'Project not found.' => 'A projekt nem található.',
'Task removed successfully.' => 'Feladat sikeresen törölve.',
'Unable to remove this task.' => 'A feladatot nem lehet törölni.',
'Remove a task' => 'Feladat törlése',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maximális méret: ',
'Unable to upload the file.' => 'Fájl feltöltése nem lehetséges.',
'Display another project' => 'Másik projekt megjelenítése',
- 'Login with my Github Account' => 'Jelentkezzen be Github fiókkal',
- 'Link my Github Account' => 'Github fiók csatolása',
- 'Unlink my Github Account' => 'Github fiók leválasztása',
'Created by %s' => 'Készítette: %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Utolsó módosítás: %Y. %m. %d. %H:%M',
'Tasks Export' => 'Feladatok exportálása',
'Tasks exportation for "%s"' => 'Feladatok exportálása: "%s"',
'Start Date' => 'Kezdés dátuma',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Csak ezekről a projektekről kérek értesítést:',
'view the task on Kanboard' => 'feladat megtekintése a Kanboardon',
'Public access' => 'Nyilvános hozzáférés',
- 'User management' => 'Felhasználók kezelése',
'Active tasks' => 'Aktív feladatok',
'Disable public access' => 'Nyilvános hozzáférés letiltása',
'Enable public access' => 'Nyilvános hozzáférés engedélyezése',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'E-mail:',
'Notifications:' => 'Értesítések:',
'Notifications' => 'Értesítések',
- 'Group:' => 'Csoport:',
- 'Regular user' => 'Default User',
'Account type:' => 'Fiók típusa:',
'Edit profile' => 'Profil szerkesztése',
'Change password' => 'Jelszó módosítása',
'Password modification' => 'Jelszó módosítása',
'External authentications' => 'Külső azonosítás',
- 'Google Account' => 'Google fiók',
- 'Github Account' => 'Github fiók',
'Never connected.' => 'Sosem csatlakozva.',
- 'No account linked.' => 'Nincs csatlakoztatott fiók.',
- 'Account linked.' => 'Fiók csatlakoztatva.',
'No external authentication enabled.' => 'A külső azonosítás nincs engedélyezve.',
'Password modified successfully.' => 'A jelszó sikeresen módosítva.',
'Unable to change the password.' => 'A jelszó módosítása sikertelen.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s a felelőst %s módosította: %s',
'New password for the user "%s"' => 'Felhasználó új jelszava: %s',
'Choose an event' => 'Válasszon eseményt',
- 'Github commit received' => 'Github commit érkezett',
- 'Github issue opened' => 'Github issue nyitás',
- 'Github issue closed' => 'Github issue zárás',
- 'Github issue reopened' => 'Github issue újranyitva',
- 'Github issue assignee change' => 'Github issue felelős változás',
- 'Github issue label change' => 'Github issue címke változás',
'Create a task from an external provider' => 'Feladat létrehozása külsős számára',
'Change the assignee based on an external username' => 'Felelős módosítása külső felhasználónév alapján',
'Change the category based on an external label' => 'Kategória módosítása külső címke alapján',
'Reference' => 'Hivatkozás',
- 'Reference: %s' => 'Hivatkozás: %s',
'Label' => 'Címke',
'Database' => 'Adatbázis',
'About' => 'Kanboard információ',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Gyakoriság másodpercben (alapértelmezetten 60 másodperc)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Gyakoriság másodpercben (0 funkció letiltva, alapértelmezetten 10 másodperc)',
'Application URL' => 'Alkalmazás URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Példa: http://example.kanboard.net/ (e-mail értesítőben használt)',
'Token regenerated.' => 'Token újragenerálva.',
'Date format' => 'Dátum formátum',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO formátum mindig elfogadott, pl: "%s" és "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Ez egy privát projekt',
'Type here to create a new sub-task' => 'Ide írva létrehozhat egy új részfeladatot',
'Add' => 'Hozzáadás',
- 'Estimated time: %s hours' => 'Becsült idő: %s óra',
- 'Time spent: %s hours' => 'Eltöltött idő: %s óra',
- 'Started on %B %e, %Y' => 'Elkezdve: %Y. %m. %d.',
'Start date' => 'Kezdés dátuma',
'Time estimated' => 'Becsült időtartam',
'There is nothing assigned to you.' => 'Nincs kiosztott feladat.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Mindenki elérheti a projektet',
'Webhooks' => 'Webhook',
'API' => 'API',
- 'Github webhooks' => 'Github webhooks',
- 'Help on Github webhooks' => 'Github Webhook súgó',
'Create a comment from an external provider' => 'Megjegyzés létrehozása külső felhasználótól',
- 'Github issue comment created' => 'Github issue megjegyzés létrehozva',
'Project management' => 'Projekt menedzsment',
'My projects' => 'Projektjeim',
'Columns' => 'Oszlopok',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Felhasználó újrafelosztás: %s',
'Clone this project' => 'Projekt másolása',
'Column removed successfully.' => 'Oszlop sikeresen törölve.',
- 'Github Issue' => 'Github issue',
'Not enough data to show the graph.' => 'Nincs elég adat a grafikonhoz.',
'Previous' => 'Előző',
'The id must be an integer' => 'Az ID csak egész szám lehet',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Alapértelmezett folyamat',
'Do you really want to remove this swimlane: "%s"?' => 'Valóban törli a folyamatot:%s ?',
'Inactive swimlanes' => 'Inaktív folyamatok',
- 'Set project manager' => 'Beállítás projekt kezelőnek',
- 'Set project member' => 'Beállítás projekt felhasználónak',
'Remove a swimlane' => 'Folyamat törlés',
- 'Rename' => 'Átnevezés',
'Show default swimlane' => 'Alapértelmezett folyamat megjelenítése',
'Swimlane modification for the project "%s"' => '%s projekt folyamatainak módosítása',
'Swimlane not found.' => 'Folyamat nem található',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Folyamatok',
'Swimlane updated successfully.' => 'Folyamat sikeresn frissítve',
'The default swimlane have been updated successfully.' => 'Az alapértelmezett folyamat sikeresen frissítve.',
- 'Unable to create your swimlane.' => 'A folyamat létrehozása sikertelen.',
'Unable to remove this swimlane.' => 'A folyamat törlése sikertelen.',
'Unable to update this swimlane.' => 'A folyamat frissítése sikertelen.',
'Your swimlane have been created successfully.' => 'A folyamat sikeresen létrehozva.',
'Example: "Bug, Feature Request, Improvement"' => 'Például: Hiba, Új funkció, Fejlesztés',
'Default categories for new projects (Comma-separated)' => 'Alapértelmezett kategóriák az új projektekben (Vesszővel elválasztva)',
- 'Gitlab commit received' => 'Gitlab commit érkezett',
- 'Gitlab issue opened' => 'Gitlab issue nyitás',
- 'Gitlab issue closed' => 'Gitlab issue zárás',
- 'Gitlab webhooks' => 'Gitlab webhooks',
- 'Help on Gitlab webhooks' => 'Gitlab webhooks súgó',
'Integrations' => 'Integráció',
'Integration with third-party services' => 'Integráció harmadik féllel',
- 'Role for this project' => 'Projekt szerepkör',
- 'Project manager' => 'Projekt kezelő',
- 'Project member' => 'Projekt felhasználó',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'A projekt kezelő képes megváltoztatni a projekt beállításait és több joggal rendelkezik mint az alap felhasználók.',
- 'Gitlab Issue' => 'Gitlab issue',
'Subtask Id' => 'Részfeladat id',
'Subtasks' => 'Részfeladatok',
'Subtasks Export' => 'Részfeladat exportálás',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Már van egy folyamatban levő részfeladata',
'Which parts of the project do you want to duplicate?' => 'A projekt mely részeit szeretné másolni?',
// 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Bitbucket commit érkezett',
- 'Bitbucket webhooks' => 'Bitbucket webhooks',
- 'Help on Bitbucket webhooks' => 'Bitbucket webhooks súgó',
'Start' => 'Kezdet',
'End' => 'Vég',
'Task age in days' => 'Feladat életkora napokban',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Ez a feladat',
'<1h' => '<1ó',
'%dh' => '%dó',
- '%b %e' => '%b %e',
'Expand tasks' => 'Feladatok lenyitása',
'Collapse tasks' => 'Feladatok összecsukása',
'Expand/collapse tasks' => 'Feladatok lenyitása/összecsukása',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Billentyű kombinációk',
'Open board switcher' => 'Tábla választó lenyitása',
'Application' => 'Alkalmazás',
- 'since %B %e, %Y at %k:%M %p' => '%Y. %m. %d. %H:%M óta',
'Compact view' => 'Kompakt nézet',
'Horizontal scrolling' => 'Vízszintes görgetés',
'Compact/wide view' => 'Kompakt/széles nézet',
'No results match:' => 'Nincs találat:',
'Currency' => 'Pénznem',
- 'Files' => 'Fájlok',
- 'Images' => 'Képek',
'Private project' => 'Privát projekt',
'AUD - Australian Dollar' => 'AUD - Ausztrál dollár',
'CAD - Canadian Dollar' => 'CAD - Kanadai dollár',
@@ -703,17 +631,12 @@ return array(
// 'The two factor authentication code is valid.' => '',
// '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' => '',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
// 'Burndown chart for "%s"' => '',
// 'Burndown chart' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
// 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
// 'Send a task by email to someone' => '',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
// 'Column change' => '',
// 'Position change' => '',
// 'Swimlane change' => '',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
// 'I want to receive notifications for:' => '',
// 'All tasks' => '',
// 'Only for tasks assigned to me' => '',
// 'Only for tasks created by me' => '',
// 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
// 'Not assigned' => '',
// 'View advanced search syntax' => '',
// 'Overview' => '',
- // '%b %e %Y' => '',
// 'Board/Calendar/List view' => '',
// 'Switch to the board view' => '',
// 'Switch to the calendar view' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
// 'New remote user' => '',
// 'New local user' => '',
// 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
// 'contributors' => '',
// 'License:' => '',
// 'License' => '',
- // 'Project Administrator' => '',
// 'Enter the text below' => '',
// 'Gantt chart for %s' => '',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php
index 8408fc11..936023dc 100644
--- a/app/Locale/id_ID/translations.php
+++ b/app/Locale/id_ID/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Hapus proyek',
'Edit the board for "%s"' => 'Rubah papan untuk « %s »',
'All projects' => 'Semua proyek',
- 'Change columns' => 'Rubah kolom',
'Add a new column' => 'Tambah kolom baru',
'Title' => 'Judul',
'Nobody assigned' => 'Tidak ada yang ditugaskan',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Buka tugas',
'Do you really want to open this task: "%s"?' => 'Apakah anda yakin akan membuka tugas ini : « %s » ?',
'Back to the board' => 'Kembali ke papan',
- 'Created on %B %e, %Y at %k:%M %p' => 'Dibuat pada tanggal %d/%m/%Y à %H:%M',
'There is nobody assigned' => 'Tidak ada orang yand ditugaskan',
'Column on the board:' => 'Kolom di dalam papan : ',
- 'Status is open' => 'Status terbuka',
- 'Status is closed' => 'Status ditutup',
'Close this task' => 'Tutup tugas ini',
'Open this task' => 'Buka tugas ini',
'There is no description.' => 'Tidak ada deskripsi.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Id diperlukan',
'The project id is required' => 'Id proyek diperlukan',
'The project name is required' => 'Nama proyek diperlukan',
- 'This project must be unique' => 'Proyek ini harus unik',
'The title is required' => 'Judul diperlukan',
'Settings saved successfully.' => 'Pengaturan berhasil disimpan.',
'Unable to save your settings.' => 'Tidak dapat menyimpan pengaturan anda.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Sedang dalam pengerjaan',
'Done' => 'Selesai',
'Application version:' => 'Versi aplikasi :',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Diselesaikan pada tanggal %d/%m/%Y à %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d/%m/%Y à %H:%M',
- 'Date created' => 'Tanggal dibuat',
- 'Date completed' => 'Tanggal diselesaikan',
'Id' => 'Id.',
'%d closed tasks' => '%d tugas yang ditutup',
'No task for this project' => 'Tidak ada tugas dalam proyek ini',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Kompleksitas',
'Task limit' => 'Batas tugas.',
'Task count' => 'Jumlah tugas',
- 'Edit project access list' => 'Modifikasi hak akses proyek',
- 'Allow this user' => 'Memperbolehkan pengguna ini',
- 'Don\'t forget that administrators have access to everything.' => 'Ingat bahwa administrator memiliki akses ke semua.',
- 'Revoke' => 'Mencabut',
- 'List of authorized users' => 'Daftar pengguna yang berwenang',
'User' => 'Pengguna',
- 'Nobody have access to this project.' => 'Tidak ada yang berwenang untuk mengakses proyek.',
'Comments' => 'Komentar',
'Write your text in Markdown' => 'Menulis teks anda didalam Markdown',
'Leave a comment' => 'Tinggalkan komentar',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Modifikasi tugas ini',
'Due Date' => 'Batas Tanggal Terakhir',
'Invalid date' => 'Tanggal tidak valid',
- 'Must be done before %B %e, %Y' => 'Harus diselesaikan sebelum tanggal %d/%m/%Y',
- '%B %e, %Y' => '%d %B %Y',
- '%b %e, %Y' => '%d/%m/%Y',
'Automatic actions' => 'Tindakan otomatis',
'Your automatic action have been created successfully.' => 'Tindakan otomatis anda berhasil dibuat.',
'Unable to create your automatic action.' => 'Tidak dapat membuat tindakan otomatis anda.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Menetapkan warna untuk pengguna tertentu',
'Column title' => 'Judul kolom',
'Position' => 'Posisi',
- 'Move Up' => 'Pindah ke atas',
- 'Move Down' => 'Pindah ke bawah',
'Duplicate to another project' => 'Duplikasi ke proyek lain',
'Duplicate' => 'Duplikasi',
'link' => 'tautan',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Komentar berhasil dihapus.',
'Unable to remove this comment.' => 'Tidak dapat menghapus komentar ini.',
'Do you really want to remove this comment?' => 'Apakah anda yakin akan menghapus komentar ini ?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Hanya administrator atau pembuat komentar yang dapat mengakses halaman ini.',
'Current password for the user "%s"' => 'Kata sandi saat ini untuk pengguna « %s »',
'The current password is required' => 'Kata sandi saat ini diperlukan',
'Wrong password' => 'Kata sandi salah',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'Otentifikasi eksternal gagal',
'Your external account is linked to your profile successfully.' => 'Akun eksternal anda berhasil dihubungkan ke profil anda.',
'Email' => 'Email',
- 'Link my Google Account' => 'Hubungkan akun Google saya',
- 'Unlink my Google Account' => 'Putuskan akun Google saya',
- 'Login with my Google Account' => 'Masuk menggunakan akun Google saya',
- 'Project not found.' => 'Proyek tidak ditemukan.',
'Task removed successfully.' => 'Tugas berhasil dihapus.',
'Unable to remove this task.' => 'Tidak dapat menghapus tugas ini.',
'Remove a task' => 'Hapus tugas',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Ukuran maksimum: ',
'Unable to upload the file.' => 'Tidak dapat mengunggah berkas.',
'Display another project' => 'Lihat proyek lain',
- 'Login with my Github Account' => 'Masuk menggunakan akun Github saya',
- 'Link my Github Account' => 'Hubungkan akun Github saya ',
- 'Unlink my Github Account' => 'Putuskan akun Github saya',
'Created by %s' => 'Dibuat oleh %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Modifikasi terakhir pada tanggal %d/%m/%Y à %H:%M',
'Tasks Export' => 'Ekspor Tugas',
'Tasks exportation for "%s"' => 'Tugas di ekspor untuk « %s »',
'Start Date' => 'Tanggal Mulai',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Saya ingin menerima pemberitahuan hanya untuk proyek-proyek yang dipilih :',
'view the task on Kanboard' => 'lihat tugas di Kanboard',
'Public access' => 'Akses publik',
- 'User management' => 'Manajemen pengguna',
'Active tasks' => 'Tugas aktif',
'Disable public access' => 'Nonaktifkan akses publik',
'Enable public access' => 'Aktifkan akses publik',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Email :',
'Notifications:' => 'Pemberitahuan :',
'Notifications' => 'Pemberitahuan',
- 'Group:' => 'Grup :',
- 'Regular user' => 'Pengguna normal',
'Account type:' => 'Tipe akun :',
'Edit profile' => 'Modifikasi profil',
'Change password' => 'Rubah kata sandri',
'Password modification' => 'Modifikasi kata sandi',
'External authentications' => 'Otentifikasi eksternal',
- 'Google Account' => 'Akun Google',
- 'Github Account' => 'Akun Github',
'Never connected.' => 'Tidak pernah terhubung.',
- 'No account linked.' => 'Tidak ada akun terhubung.',
- 'Account linked.' => 'Akun terhubung.',
'No external authentication enabled.' => 'Tidak ada otentifikasi eksternal yang aktif.',
'Password modified successfully.' => 'Kata sandi berhasil dimodifikasi.',
'Unable to change the password.' => 'Tidak dapat merubah kata sandir.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s mengubah orang yang ditugaskan dari tugas %s ke %s',
'New password for the user "%s"' => 'Kata sandi baru untuk pengguna « %s »',
'Choose an event' => 'Pilih acara',
- 'Github commit received' => 'Menerima komit dari Github',
- 'Github issue opened' => 'Tiket Github dibuka',
- 'Github issue closed' => 'Tiket Github ditutup',
- 'Github issue reopened' => 'Tiket Github dibuka kembali',
- 'Github issue assignee change' => 'Rubah penugasan tiket Github',
- 'Github issue label change' => 'Perubahan label pada tiket Github',
'Create a task from an external provider' => 'Buat tugas dari pemasok eksternal',
'Change the assignee based on an external username' => 'Rubah penugasan berdasarkan nama pengguna eksternal',
'Change the category based on an external label' => 'Rubah kategori berdasarkan label eksternal',
'Reference' => 'Referensi',
- 'Reference: %s' => 'Referensi : %s',
'Label' => 'Label',
'Database' => 'Basis data',
'About' => 'Tentang',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frequensi dalam detik (standar 60 detik)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frequensi dalam detik (0 untuk menonaktifkan fitur ini, standar 10 detik)',
'Application URL' => 'URL Aplikasi',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Contoh : http://exemple.kanboard.net/ (digunakan untuk pemberitahuan email)',
'Token regenerated.' => 'Token diregenerasi.',
'Date format' => 'Format tanggal',
'ISO format is always accepted, example: "%s" and "%s"' => 'Format ISO selalu diterima, contoh : « %s » et « %s »',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Proyek ini adalah pribadi',
'Type here to create a new sub-task' => 'Ketik disini untuk membuat sub-tugas baru',
'Add' => 'Tambah',
- 'Estimated time: %s hours' => 'Perkiraan waktu: %s jam',
- 'Time spent: %s hours' => 'Waktu dihabiskan : %s jam',
- 'Started on %B %e, %Y' => 'Dimulai pada %d/%m/%Y',
'Start date' => 'Tanggal mulai',
'Time estimated' => 'Perkiraan waktu',
'There is nothing assigned to you.' => 'Tidak ada yang diberikan kepada anda.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Semua orang mendapat akses untuk proyek ini.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Webhook Github',
- 'Help on Github webhooks' => 'Bantuan pada webhook Github',
'Create a comment from an external provider' => 'Buat komentar dari pemasok eksternal',
- 'Github issue comment created' => 'Komentar dibuat pada tiket Github',
'Project management' => 'Manajemen proyek',
'My projects' => 'Proyek saya',
'Columns' => 'Kolom',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Partisi ulang pengguna untuk « %s »',
'Clone this project' => 'Gandakan proyek ini',
'Column removed successfully.' => 'Kolom berhasil dihapus.',
- 'Github Issue' => 'Tiket Github',
'Not enough data to show the graph.' => 'Tidak cukup data untuk menampilkan grafik.',
'Previous' => 'Sebelumnya',
'The id must be an integer' => 'Id harus integer',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Standar swimlane',
'Do you really want to remove this swimlane: "%s"?' => 'Apakah anda yakin akan menghapus swimlane ini : « %s » ?',
'Inactive swimlanes' => 'Swimlanes tidak aktif',
- 'Set project manager' => 'Masukan manajer proyek',
- 'Set project member' => 'Masukan anggota proyek ',
'Remove a swimlane' => 'Supprimer une swimlane',
- 'Rename' => 'Ganti nama',
'Show default swimlane' => 'Perlihatkan standar swimlane',
'Swimlane modification for the project "%s"' => 'Modifikasi swimlane untuk proyek « %s »',
'Swimlane not found.' => 'Swimlane tidak ditemukan.',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane berhasil diperbaharui.',
'The default swimlane have been updated successfully.' => 'Standar swimlane berhasil diperbaharui.',
- 'Unable to create your swimlane.' => 'Tidak dapat membuat swimlane anda.',
'Unable to remove this swimlane.' => 'Tidak dapat menghapus swimlane ini.',
'Unable to update this swimlane.' => 'Tidak dapat memperbaharui swimlane ini.',
'Your swimlane have been created successfully.' => 'Swimlane anda berhasil dibuat.',
'Example: "Bug, Feature Request, Improvement"' => 'Contoh: « Insiden, Permintaan Fitur, Perbaikan »',
'Default categories for new projects (Comma-separated)' => 'Standar kategori untuk proyek baru (dipisahkan dengan koma)',
- 'Gitlab commit received' => 'Menerima komit Gitlab',
- 'Gitlab issue opened' => 'Tiket Gitlab dibuka',
- 'Gitlab issue closed' => 'Tiket Gitlab ditutup',
- 'Gitlab webhooks' => 'Webhook Gitlab',
- 'Help on Gitlab webhooks' => 'Bantuan pada webhook Gitlab',
'Integrations' => 'Integrasi',
'Integration with third-party services' => 'Integrasi dengan layanan pihak ketiga',
- 'Role for this project' => 'Peran untuk proyek ini',
- 'Project manager' => 'Manajer proyek',
- 'Project member' => 'Anggota proyek',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Seorang manajer proyek dapat mengubah pengaturan proyek dan memiliki lebih banyak keistimewaan dibandingkan dengan pengguna biasa.',
- 'Gitlab Issue' => 'Tiket Gitlab',
'Subtask Id' => 'Id Subtugas',
'Subtasks' => 'Subtugas',
'Subtasks Export' => 'Ekspor Subtugas',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Anda sudah ada satu subtugas dalam proses',
'Which parts of the project do you want to duplicate?' => 'Bagian dalam proyek mana yang ingin anda duplikasi?',
'Disallow login form' => 'Larang formulir masuk',
- 'Bitbucket commit received' => 'Menerima komit Bitbucket',
- 'Bitbucket webhooks' => 'Webhook Bitbucket',
- 'Help on Bitbucket webhooks' => 'Bantuan pada webhook Bitbucket',
'Start' => 'Mulai',
'End' => 'Selesai',
'Task age in days' => 'Usia tugas dalam hari',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Tugas ini',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%e %b',
'Expand tasks' => 'Perluas tugas',
'Collapse tasks' => 'Lipat tugas',
'Expand/collapse tasks' => 'Perluas/lipat tugas',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'pintas keyboard',
'Open board switcher' => 'Buka table switcher',
'Application' => 'Aplikasi',
- 'since %B %e, %Y at %k:%M %p' => 'sejak %d/%m/%Y à %H:%M',
'Compact view' => 'Tampilan kompak',
'Horizontal scrolling' => 'Horisontal bergulir',
'Compact/wide view' => 'Beralih antara tampilan kompak dan diperluas',
'No results match:' => 'Tidak ada hasil :',
'Currency' => 'Mata uang',
- 'Files' => 'Arsip',
- 'Images' => 'Gambar',
'Private project' => 'Proyek pribadi',
'AUD - Australian Dollar' => 'AUD - Dollar Australia',
'CAD - Canadian Dollar' => 'CAD - Dollar Kanada',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'Kode dua faktor kode otentifikasi valid.',
'Code' => 'Kode',
'Two factor authentication' => 'Dua faktor otentifikasi',
- 'Enable/disable two factor authentication' => 'Matikan/hidupkan dua faktor otentifikasi',
'This QR code contains the key URI: ' => 'kode QR ini mengandung kunci URI : ',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Menyimpan kunci rahasia ini dalam perangkat lunak TOTP anda(misalnya Googel Authenticator atau FreeOTP).',
'Check my code' => 'Memeriksa kode saya',
'Secret key: ' => 'Kunci rahasia : ',
'Test your device' => 'Menguji perangkat anda',
'Assign a color when the task is moved to a specific column' => 'Menetapkan warna ketika tugas tersebut dipindahkan ke kolom tertentu',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'diunggah oleh %s',
- 'uploaded on: %s' => 'diunggah pada %s',
- 'size: %s' => 'ukuran : %s',
'Burndown chart for "%s"' => 'Grafik Burndown untku « %s »',
'Burndown chart' => 'Grafik Burndown',
'This chart show the task complexity over the time (Work Remaining).' => 'Grafik ini menunjukkan kompleksitas tugas dari waktu ke waktu (Sisa Pekerjaan).',
@@ -722,7 +645,6 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Mengambil screenshot dan tekan CTRL + V atau ⌘ + V untuk paste di sini.',
'Screenshot uploaded successfully.' => 'Screenshot berhasil diunggah.',
'SEK - Swedish Krona' => 'SEK - Krona Swedia',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Identifier proyek adalah kode alfanumerik opsional digunakan untuk mengidentifikasi proyek Anda.',
'Identifier' => 'Identifier',
'Disable two factor authentication' => 'Matikan dua faktor otentifikasi',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Apakah anda yakin akan mematikan dua faktor otentifikasi untuk pengguna ini : « %s » ?',
@@ -731,7 +653,6 @@ return array(
'A task cannot be linked to itself' => 'Sebuah tugas tidak dapat dikaitkan dengan dirinya sendiri',
'The exact same link already exists' => 'Tautan yang sama persis sudah ada',
'Recurrent task is scheduled to be generated' => 'Tugas berulang dijadwalkan akan dihasilkan',
- 'Recurring information' => 'Informasi berulang',
'Score' => 'Skor',
'The identifier must be unique' => 'Identifier harus unik',
'This linked task id doesn\'t exists' => 'Id tugas terkait tidak ada',
@@ -777,21 +698,10 @@ return array(
'User that will receive the email' => 'Pengguna yang akan menerima email',
'Email subject' => 'Subjek Email',
'Date' => 'Tanggal',
- 'By @%s on Bitbucket' => 'Oleh @%s pada Bitbucket',
- 'Bitbucket Issue' => 'Tiket Bitbucket',
- 'Commit made by @%s on Bitbucket' => 'Komit dibuat oleh @%s pada Bitbucket',
- 'Commit made by @%s on Github' => 'Komit dibuat oleh @%s pada Github',
- 'By @%s on Github' => 'Oleh @%s pada Github',
- 'Commit made by @%s on Gitlab' => 'Komit dibuat oleh @%s pada Gitlab',
'Add a comment log when moving the task between columns' => 'Menambahkan log komentar ketika memindahkan tugas antara kolom',
'Move the task to another column when the category is changed' => 'Pindahkan tugas ke kolom lain ketika kategori berubah',
'Send a task by email to someone' => 'Kirim tugas melalui email ke seseorang',
'Reopen a task' => 'Membuka kembali tugas',
- 'Bitbucket issue opened' => 'Tiket Bitbucket dibuka',
- 'Bitbucket issue closed' => 'Tiket Bitbucket ditutup',
- 'Bitbucket issue reopened' => 'Tiket Bitbucket dibuka kembali',
- 'Bitbucket issue assignee change' => 'Perubahan penugasan tiket Bitbucket',
- 'Bitbucket issue comment created' => 'Komentar dibuat tiket Bitbucket',
'Column change' => 'Kolom berubah',
'Position change' => 'Posisi berubah',
'Swimlane change' => 'Swimlane berubah',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'Field « %s » telah diperbaharui',
'The description have been modified' => 'Deskripsi telah dimodifikasi',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Apakah anda yakin akan menutup tugas « %s » beserta semua sub-tugasnya ?',
- 'Swimlane: %s' => 'Swimlane : %s',
'I want to receive notifications for:' => 'Saya ingin menerima pemberitahuan untuk :',
'All tasks' => 'Semua tugas',
'Only for tasks assigned to me' => 'Hanya untuk tugas yang ditugaskan ke saya',
'Only for tasks created by me' => 'Hanya untuk tugas yang dibuat oleh saya',
'Only for tasks created by me and assigned to me' => 'Hanya untuk tugas yang dibuat oleh saya dan ditugaskan ke saya',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%d/%m/%Y %H:%M',
- 'New due date: %B %e, %Y' => 'Tanggal jatuh tempo baru : %d/%m/%Y',
- 'Start date changed: %B %e, %Y' => 'Tanggal mulai berubah : %d/%m/%Y',
- '%k:%M %p' => '%H:%M',
'%%Y-%%m-%%d' => '%%d/%%m/%%Y',
'Total for all columns' => 'Total untuk semua kolom',
'You need at least 2 days of data to show the chart.' => 'Anda memerlukan setidaknya 2 hari dari data yang menunjukkan grafik.',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Tidak ditugaskan',
'View advanced search syntax' => 'Lihat sintaks pencarian lanjutan',
'Overview' => 'Ikhtisar',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Tampilan Papan/Kalender/Daftar',
'Switch to the board view' => 'Beralih ke tampilan papan',
'Switch to the calendar view' => 'Beralih ke tampilan kalender',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Grafik ini menunjukkan memimpin rata-rata dan waktu siklus untuk %d tugas terakhir dari waktu ke waktu.',
'Average time into each column' => 'Rata-rata waktu ke setiap kolom',
'Lead and cycle time' => 'Lead dan siklus waktu',
- 'Google Authentication' => 'Google Otentifikasi',
- 'Help on Google authentication' => 'Bantuan pada otentifikasi Google',
- 'Github Authentication' => 'Otentifikasi Github',
- 'Help on Github authentication' => 'Bantuan pada otentifikasi Github',
'Lead time: ' => 'Lead time : ',
'Cycle time: ' => 'Siklus waktu : ',
'Time spent into each column' => 'Waktu yang dihabiskan di setiap kolom',
@@ -906,18 +805,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Jika tugas tidak ditutup waktu saat ini yang digunakan sebagai pengganti tanggal penyelesaian.',
'Set automatically the start date' => 'Secara otomatis mengatur tanggal mulai',
'Edit Authentication' => 'Modifikasi Otentifikasi',
- 'Google Id' => 'Id Google',
- 'Github Id' => 'Id Github',
'Remote user' => 'Pengguna jauh',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Pengguna jauh tidak menyimpan kata sandi mereka dalam basis data Kanboard, contoh: akun LDAP, Google dan Github.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Jika anda mencentang kotak "Larang formulir login", kredensial masuk ke formulis login akan diabaikan.',
- 'By @%s on Gitlab' => 'Dengan @%s pada Gitlab',
- 'Gitlab issue comment created' => 'Komentar dibuat pada tiket Gitlab',
'New remote user' => 'Pengguna baru jauh',
'New local user' => 'Pengguna baru lokal',
'Default task color' => 'Standar warna tugas',
- 'Hide sidebar' => 'Sembunyikan sidebar',
- 'Expand sidebar' => 'Perluas sidebar',
'This feature does not work with all browsers.' => 'Fitur ini tidak dapat digunakan di semua browsers',
'There is no destination project available.' => 'Tidak ada destinasi proyek yang tersedia.',
'Trigger automatically subtask time tracking' => 'Otomatis memicu pelacakan untuk subtugas',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'kontributor',
'License:' => 'Lisensi :',
'License' => 'Lisensi',
- 'Project Administrator' => 'Administrator proyek',
'Enter the text below' => 'Masukkan teks di bawah',
'Gantt chart for %s' => 'Grafik Gantt untuk %s',
'Sort by position' => 'Urutkan berdasarkan posisi',
@@ -952,11 +844,9 @@ return array(
'open file' => 'buka berkas',
'End date' => 'Waktu berakhir',
'Users overview' => 'Ikhtisar pengguna',
- 'Managers' => 'Manajer',
'Members' => 'Anggota',
'Shared project' => 'Proyek bersama',
'Project managers' => 'Manajer proyek',
- 'Project members' => 'Anggota proyek',
'Gantt chart for all projects' => 'Grafik Gantt untuk semua proyek',
'Projects list' => 'Daftar proyek',
'Gantt chart for this project' => 'Grafik Gantt untuk proyek ini',
@@ -964,26 +854,16 @@ return array(
'End date:' => 'Waktu berakhir :',
'There is no start date or end date for this project.' => 'Tidak ada waktu mulai atau waktu berakhir untuk proyek ini',
'Projects Gantt chart' => 'Proyek grafik Gantt',
- 'Start date: %s' => 'Waktu mulai : %s',
- 'End date: %s' => 'Waktu berakhir : %s',
'Link type' => 'Tipe tautan',
'Change task color when using a specific task link' => 'Rubah warna tugas ketika menggunakan tautan tugas yang spesifik',
'Task link creation or modification' => 'Tautan pembuatan atau modifikasi tugas ',
- 'Login with my Gitlab Account' => 'Masuk menggunakan akun Gitlab saya',
'Milestone' => 'Milestone',
- 'Gitlab Authentication' => 'Authentification Gitlab',
- 'Help on Gitlab authentication' => 'Bantuan pada otentifikasi Gitlab',
- 'Gitlab Id' => 'Id Gitlab',
- 'Gitlab Account' => 'Akun Gitlab',
- 'Link my Gitlab Account' => 'Hubungkan akun Gitlab saya',
- 'Unlink my Gitlab Account' => 'Putuskan akun Gitlab saya',
'Documentation: %s' => 'Dokumentasi : %s',
'Switch to the Gantt chart view' => 'Beralih ke tampilan grafik Gantt',
'Reset the search/filter box' => 'Atur ulang pencarian/kotak filter',
'Documentation' => 'Dokumentasi',
'Table of contents' => 'Daftar isi',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Bantuan dengan izin proyek',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ 'Project members' => 'Anggota proyek',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php
index 08bf7225..a735128f 100644
--- a/app/Locale/it_IT/translations.php
+++ b/app/Locale/it_IT/translations.php
@@ -4,14 +4,14 @@ return array(
'number.decimals_separator' => ',',
'number.thousands_separator' => '.',
'None' => 'Nessuno',
- 'edit' => 'modificare',
- 'Edit' => 'Modificare',
- 'remove' => 'cancellare',
- 'Remove' => 'Cancellare',
- 'Update' => 'Aggiornare',
+ 'edit' => 'modifica',
+ 'Edit' => 'Modifica',
+ 'remove' => 'cancella',
+ 'Remove' => 'Cancella',
+ 'Update' => 'Aggiorna',
'Yes' => 'Si',
'No' => 'No',
- 'cancel' => 'annullare',
+ 'cancel' => 'annulla',
'or' => 'o',
'Yellow' => 'Giallo',
'Blue' => 'Blu',
@@ -20,68 +20,67 @@ return array(
'Red' => 'Rosso',
'Orange' => 'Arancione',
'Grey' => 'Grigio',
- // 'Brown' => '',
- // 'Deep Orange' => '',
- // 'Dark Grey' => '',
- // 'Pink' => '',
- // 'Teal' => '',
- // 'Cyan' => '',
+ 'Brown' => 'Marrone',
+ 'Deep Orange' => 'Arancio scuro',
+ 'Dark Grey' => 'Grigio scuro',
+ 'Pink' => 'Rosa',
+ 'Teal' => 'Verde foglia di tè',
+ 'Cyan' => 'Ciano',
// 'Lime' => '',
- // 'Light Green' => '',
- // 'Amber' => '',
- 'Save' => 'Salvare',
- 'Login' => 'Entra',
+ 'Light Green' => 'Verde chiaro',
+ 'Amber' => 'Ambra',
+ 'Save' => 'Salva',
+ 'Login' => 'Accedi',
'Official website:' => 'Sito web ufficiale:',
'Unassigned' => 'Non assegnato',
- 'View this task' => 'Vedere questo compito',
- 'Remove user' => 'Cancellare un utente',
- 'Do you really want to remove this user: "%s"?' => 'Veramente vuoi cancellare questo utente: « %s » ?',
- 'New user' => 'Aggiungere un utente',
+ 'View this task' => 'Visualizza questo task',
+ 'Remove user' => 'Cancella un utente',
+ 'Do you really want to remove this user: "%s"?' => 'Veramente vuoi cancellare questo utente: "%s" ?',
+ 'New user' => 'Aggiungi un utente',
'All users' => 'Tutti gli utenti',
'Username' => 'Nome utente',
'Password' => 'Password',
'Administrator' => 'Amministratore',
- 'Sign in' => 'Iscriversi',
+ 'Sign in' => 'Accedi',
'Users' => 'Utenti',
'No user' => 'Nessun utente',
'Forbidden' => 'Vietato',
'Access Forbidden' => 'Accesso vietato',
- 'Edit user' => 'Modificare un utente',
- 'Logout' => 'Uscire',
+ 'Edit user' => 'Modifica un utente',
+ 'Logout' => 'Esci',
'Bad username or password' => 'Utente o password errati',
- 'Edit project' => 'Modificare progetto',
+ 'Edit project' => 'Modifica progetto',
'Name' => 'Nome',
'Projects' => 'Progetti',
'No project' => 'Nessun progetto',
'Project' => 'Progetto',
'Status' => 'Stato',
- 'Tasks' => 'Compiti',
+ 'Tasks' => 'Tasks',
'Board' => 'Bacheca',
'Actions' => 'Azioni',
'Inactive' => 'Inattivo',
'Active' => 'Attivo',
- 'Add this column' => 'Aggiungere questa colonna',
- '%d tasks on the board' => '%d compiti sulla bacheca',
- '%d tasks in total' => '%d compiti in totale',
- 'Unable to update this board.' => 'Non si può aggiornare questa bacheca.',
- 'Edit board' => 'Modificare questa bacheca',
- 'Disable' => 'Disattivare',
- 'Enable' => 'Attivare',
+ 'Add this column' => 'Aggiungi questa colonna',
+ '%d tasks on the board' => '%d task sulla bacheca',
+ '%d tasks in total' => '%d task in totale',
+ 'Unable to update this board.' => 'Impossibile aggiornare questa bacheca.',
+ 'Edit board' => 'Modifica questa bacheca',
+ 'Disable' => 'Disattiva',
+ 'Enable' => 'Attiva',
'New project' => 'Nuovo progetto',
- 'Do you really want to remove this project: "%s"?' => 'Veramente vuoi eliminare questo progetto: « %s » ?',
- 'Remove project' => 'Cancellare il progetto',
- 'Edit the board for "%s"' => 'Modificare la bacheca per « %s »',
+ 'Do you really want to remove this project: "%s"?' => 'Veramente vuoi eliminare il seguente progetto: "%s" ?',
+ 'Remove project' => 'Cancella il progetto',
+ 'Edit the board for "%s"' => 'Modifica la bacheca per "%s"',
'All projects' => 'Tutti i progetti',
- 'Change columns' => 'Cambiare le colonne',
- 'Add a new column' => 'Aggiungere una nuova colonna',
+ 'Add a new column' => 'Aggiungi una nuova colonna',
'Title' => 'Titolo',
'Nobody assigned' => 'Nessuno assegnato',
'Assigned to %s' => 'Assegnato a %s',
- 'Remove a column' => 'Cancellare questa colonna',
- 'Remove a column from a board' => 'Cancellare una colonna da una bacheca',
- 'Unable to remove this column.' => 'Non si può cancellare questa colonna.',
- 'Do you really want to remove this column: "%s"?' => 'Veramente desideri cancellare questa colonna : « %s » ?',
- 'This action will REMOVE ALL TASKS associated to this column!' => 'Questa azione cancellerà TUTTI I COMPITI legati a questa colonna!',
+ 'Remove a column' => 'Cancella questa colonna',
+ 'Remove a column from a board' => 'Cancella una colonna da una bacheca',
+ 'Unable to remove this column.' => 'Impossibile cancellare questa colonna.',
+ 'Do you really want to remove this column: "%s"?' => 'Veramente desideri cancellare questa colonna: "%s" ?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'Questa azione cancellerà TUTTI I TASK legati a questa colonna!',
'Settings' => 'Impostazioni',
'Application settings' => 'Impostazioni dell\'applicazione',
'Language' => 'Lingua',
@@ -92,25 +91,22 @@ return array(
'Optimize the database' => 'Ottimizare la base dati',
'(VACUUM command)' => '(Comando VACUUM)',
'(Gzip compressed Sqlite file)' => '(File Sqlite compresso in Gzip)',
- 'Close a task' => 'Chiudere un compito',
- 'Edit a task' => 'Modificare un compito',
- 'Column' => 'colonna',
+ 'Close a task' => 'Chiudi un task',
+ 'Edit a task' => 'Modifica un task',
+ 'Column' => 'Colonna',
'Color' => 'Colore',
- 'Assignee' => 'Persona assegnata',
- 'Create another task' => 'Creare un nuovo compito',
- 'New task' => 'Nuovo compito',
- 'Open a task' => 'Aprire un compito',
- 'Do you really want to open this task: "%s"?' => 'Veramente desideri aprire questo compito: « %s » ?',
- 'Back to the board' => 'Tornare alla bacheca',
- 'Created on %B %e, %Y at %k:%M %p' => 'Creato il %B %e, %Y alle %k:%M %p',
- 'There is nobody assigned' => 'Non c\'è nessuno assegnato a questo compito',
+ 'Assignee' => 'Assegnatario',
+ 'Create another task' => 'Crea un nuovo task',
+ 'New task' => 'Nuovo task',
+ 'Open a task' => 'Apri un task',
+ 'Do you really want to open this task: "%s"?' => 'Veramente desideri aprire questo task: "%s" ?',
+ 'Back to the board' => 'Torna alla bacheca',
+ 'There is nobody assigned' => 'Nessuno è assegnato a questo task',
'Column on the board:' => 'Colonna sulla bacheca: ',
- 'Status is open' => 'Stato aperto',
- 'Status is closed' => 'stato chiuso',
- 'Close this task' => 'Chiudere questo compito',
- 'Open this task' => 'Aprire questo compito',
- 'There is no description.' => 'Non c\'è descrizione.',
- 'Add a new task' => 'Aggiungere un nuovo compito',
+ 'Close this task' => 'Chiudi questo task',
+ 'Open this task' => 'Apri questo task',
+ 'There is no description.' => 'Nessuna descrizione presente.',
+ 'Add a new task' => 'Aggiungere un nuovo task',
'The username is required' => 'Si richiede un nome di utente',
'The maximum length is %d characters' => 'La lunghezza massima è di %d caratteri',
'The minimum length is %d characters' => 'La lunghezza minima è di %d caratteri',
@@ -124,84 +120,70 @@ return array(
'The id is required' => 'Si richiede l\'identificatore',
'The project id is required' => 'Si richiede l\'identificatore del progetto',
'The project name is required' => 'Si richiede il nome del progetto',
- 'This project must be unique' => 'Il nome del progetto deve essere unico',
'The title is required' => 'Si richiede un titolo',
'Settings saved successfully.' => 'Impostazioni salvate correttamente.',
- 'Unable to save your settings.' => 'Non si possono salvare le impostazioni.',
+ 'Unable to save your settings.' => 'Impossibile salvare le impostazioni.',
'Database optimization done.' => 'Ottimizzazione della base dati conclusa.',
'Your project have been created successfully.' => 'Il tuo progetto è stato creato correttamente.',
- 'Unable to create your project.' => 'Non si può creare il progetto.',
+ 'Unable to create your project.' => 'Impossibile creare il progetto.',
'Project updated successfully.' => 'Progetto aggiornato correttamente.',
- 'Unable to update this project.' => 'Non si può aggiornare il progetto.',
- 'Unable to remove this project.' => 'Non si può cancellare questo progetto.',
+ 'Unable to update this project.' => 'Impossibile aggiornare il progetto.',
+ 'Unable to remove this project.' => 'Impossibile cancellare questo progetto.',
'Project removed successfully.' => 'Progetto cancellato correttamente.',
'Project activated successfully.' => 'Progetto attivato correttamente.',
- 'Unable to activate this project.' => 'Non si può attivare il progetto.',
+ 'Unable to activate this project.' => 'Impossibile attivare il progetto.',
'Project disabled successfully.' => 'Progetto disattivato correttamente.',
- 'Unable to disable this project.' => 'Non si può disattivare il progetto.',
- 'Unable to open this task.' => 'Non si può aprire questo compito.',
- 'Task opened successfully.' => 'Il compito è stato aperto correttamente.',
- 'Unable to close this task.' => 'Non si può chiudere questo compito.',
- 'Task closed successfully.' => 'Compito chiuso correttamente.',
- 'Unable to update your task.' => 'Non si può modificare questo compito.',
- 'Task updated successfully.' => 'Compito modificato correttamente.',
- 'Unable to create your task.' => 'Non si può creare questo compito.',
- 'Task created successfully.' => 'Compito creato correttamente.',
+ 'Unable to disable this project.' => 'Impossibile disattivare il progetto.',
+ 'Unable to open this task.' => 'Impossibile aprire questo task.',
+ 'Task opened successfully.' => 'Il task è stato aperto correttamente.',
+ 'Unable to close this task.' => 'Impossibile chiudere questo task.',
+ 'Task closed successfully.' => 'Task chiuso correttamente.',
+ 'Unable to update your task.' => 'Impossibile modificare questo task.',
+ 'Task updated successfully.' => 'Task modificato correttamente.',
+ 'Unable to create your task.' => 'Impossibile creare questo task.',
+ 'Task created successfully.' => 'Task creato correttamente.',
'User created successfully.' => 'Utente creato correttamente.',
- 'Unable to create your user.' => 'Non si può creare l\'utente.',
+ 'Unable to create your user.' => 'Impossibile creare l\'utente.',
'User updated successfully.' => 'Utente aggiornato correttamente.',
- 'Unable to update your user.' => 'Non si può aggiornare questo utente.',
+ 'Unable to update your user.' => 'Impossibile aggiornare questo utente.',
'User removed successfully.' => 'Utente cancellato correttamente.',
- 'Unable to remove this user.' => 'Non si può cancellare questo utente.',
+ 'Unable to remove this user.' => 'Impossibile cancellare questo utente.',
'Board updated successfully.' => 'Bacheca aggiornata correttamente.',
'Ready' => 'Pronto',
'Backlog' => 'In attesa',
'Work in progress' => 'In corso',
'Done' => 'Fatto',
'Application version:' => 'Versione dell\'applicazione:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Completato il %B %e, %Y alle %k:%M %p',
- // '%B %e, %Y at %k:%M %p' => '',
- 'Date created' => 'Data di creazione',
- 'Date completed' => 'Data di termine',
- 'Id' => 'Identificatore',
- '%d closed tasks' => '%d compiti chiusi',
- 'No task for this project' => 'Nessun compito per questo progetto',
+ // 'Id' => '',
+ '%d closed tasks' => '%d task chiusi',
+ 'No task for this project' => 'Nessun task per questo progetto',
'Public link' => 'Link pubblico',
- 'Change assignee' => 'Cambiare la persona assegnata',
- 'Change assignee for the task "%s"' => 'Cambiare la persona assegnata per il compito « %s »',
+ 'Change assignee' => 'Cambia l\'assegnatario',
+ 'Change assignee for the task "%s"' => 'Cambia l\'assegnatario per il task "%s"',
'Timezone' => 'Fuso orario',
- 'Sorry, I didn\'t find this information in my database!' => 'Mi dispiace, non ho trovato questa informazione sulla base dati!',
+ 'Sorry, I didn\'t find this information in my database!' => 'Spiacente, non ho trovato questa informazione sul database!',
'Page not found' => 'Pagina non trovata',
'Complexity' => 'Complessità',
- 'Task limit' => 'Numero massimo di compiti',
- 'Task count' => 'Numero di compiti',
- 'Edit project access list' => 'Modificare i permessi del progetto',
- 'Allow this user' => 'Permettere a questo utente',
- 'Don\'t forget that administrators have access to everything.' => 'Non dimenticare che gli amministratori hanno accesso a tutto.',
- 'Revoke' => 'Revocare',
- 'List of authorized users' => 'Lista di utenti autorizzati',
+ 'Task limit' => 'Limite di task',
+ 'Task count' => 'Numero di task',
'User' => 'Utente',
- 'Nobody have access to this project.' => 'Nessuno ha accesso a questo progetto.',
'Comments' => 'Commenti',
'Write your text in Markdown' => 'Scrivi il testo in Markdown',
- 'Leave a comment' => 'Lasciare un commento',
+ 'Leave a comment' => 'Lascia un commento',
'Comment is required' => 'Si richiede un commento',
- 'Leave a description' => 'Lasciare una descrizione',
+ 'Leave a description' => 'Lascia una descrizione',
'Comment added successfully.' => 'Commenti aggiunti correttamente.',
- 'Unable to create your comment.' => 'Non si può creare questo commento.',
- 'Edit this task' => 'Modificare questo compito',
+ 'Unable to create your comment.' => 'Impossibile creare questo commento.',
+ 'Edit this task' => 'Modifica questo task',
'Due Date' => 'Data di scadenza',
- 'Invalid date' => 'Data sbagliata',
- 'Must be done before %B %e, %Y' => 'Deve essere completato prima del %B %e, %Y',
- // '%B %e, %Y' => '',
- // '%b %e, %Y' => '',
+ 'Invalid date' => 'Data non valida',
'Automatic actions' => 'Azioni automatiche',
'Your automatic action have been created successfully.' => 'l\'azione automatica è stata creata correttamente.',
- 'Unable to create your automatic action.' => 'Non si può creare quest\'azione automatica.',
+ 'Unable to create your automatic action.' => 'Impossibile creare quest\'azione automatica.',
'Remove an action' => 'Cancellare un\'azione',
- 'Unable to remove this action.' => 'Non si può cancellare questa azione.',
+ 'Unable to remove this action.' => 'Impossibile cancellare questa azione.',
'Action removed successfully.' => 'Azione cancellata correttamente.',
- 'Automatic actions for the project "%s"' => 'Azioni automatiche per questo progetto « %s »',
+ 'Automatic actions for the project "%s"' => 'Azioni automatiche per il progetto "%s"',
'Defined actions' => 'Azioni definite',
'Add an action' => 'Aggiungi un\'azione',
'Event name' => 'Nome dell\'evento',
@@ -209,43 +191,40 @@ return array(
'Action parameters' => 'Parametri d\'azione',
'Action' => 'Azione',
'Event' => 'Evento',
- 'When the selected event occurs execute the corresponding action.' => 'Quando accade l\'evento selezionato, eseguire l\'azione corrispondente.',
- 'Next step' => 'Passo seguente',
+ 'When the selected event occurs execute the corresponding action.' => 'Quando si verifica l\'evento selezionato, eseguire l\'azione corrispondente.',
+ 'Next step' => 'Passo successivo',
'Define action parameters' => 'Definire i parametri dell\'azione',
- 'Save this action' => 'Salvare questa azione',
- 'Do you really want to remove this action: "%s"?' => 'Veramente vuole cancellare questa azione « %s » ?',
- 'Remove an automatic action' => 'Cancellare un\'azione automatica',
- 'Assign the task to a specific user' => 'Assegnare questo compito a un utente specifico',
- 'Assign the task to the person who does the action' => 'Assegnare il compito all\'utente che svolge l\'azione',
- 'Duplicate the task to another project' => 'Duplicare il compito in altro progetto',
- 'Move a task to another column' => 'Muovere un compito in un\'altra colonna',
- 'Task modification' => 'Modifica di un compito',
- 'Task creation' => 'Creazione di un compito',
- 'Closing a task' => 'Chiudere un compito',
+ 'Save this action' => 'Salva questa azione',
+ 'Do you really want to remove this action: "%s"?' => 'Vuoi veramente cancellare la seguente azione: "%s"?',
+ 'Remove an automatic action' => 'Cancella un\'azione automatica',
+ 'Assign the task to a specific user' => 'Assegna il task ad un utente specifico',
+ 'Assign the task to the person who does the action' => 'Assegna il task all\'utente che compie l\'azione',
+ 'Duplicate the task to another project' => 'Duplica il task in altro progetto',
+ 'Move a task to another column' => 'Sposta un task in un\'altra colonna',
+ 'Task modification' => 'Modifica di un task',
+ 'Task creation' => 'Creazione di un task',
+ 'Closing a task' => 'Chiusura di un task',
'Assign a color to a specific user' => 'Assegna un colore ad un utente specifico',
'Column title' => 'Titolo della colonna',
'Position' => 'Posizione',
- 'Move Up' => 'Alzare',
- 'Move Down' => 'Abassare',
- 'Duplicate to another project' => 'Duplicare in un altro progetto',
- 'Duplicate' => 'Duplicare',
- 'link' => 'link',
+ 'Duplicate to another project' => 'Duplica in un altro progetto',
+ 'Duplicate' => 'Duplica',
+ 'link' => 'relazione',
'Comment updated successfully.' => 'Commento aggiornato correttamente.',
- 'Unable to update your comment.' => 'Non si può aggiornare questo commento.',
- 'Remove a comment' => 'Cancellare un commento',
+ 'Unable to update your comment.' => 'Impossibile aggiornare questo commento.',
+ 'Remove a comment' => 'Cancella un commento',
'Comment removed successfully.' => 'Commento cancellato correttamente.',
- 'Unable to remove this comment.' => 'Non si può cancellare questo commento.',
- 'Do you really want to remove this comment?' => 'Desidera cancellare questo commento?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Solo gli amministratori o l\'autore del commento hanno accesso a questa pagina.',
- 'Current password for the user "%s"' => 'Password attuale per l\'utente: « %s »',
+ 'Unable to remove this comment.' => 'Impossibile cancellare questo commento.',
+ 'Do you really want to remove this comment?' => 'Vuoi davvero cancellare questo commento?',
+ 'Current password for the user "%s"' => 'Password attuale per l\'utente "%s"',
'The current password is required' => 'Si richiede la password attuale',
- 'Wrong password' => 'password sbagliata',
+ 'Wrong password' => 'Password errata',
'Unknown' => 'Sconociuto',
- 'Last logins' => 'Ultimi ingressi',
- 'Login date' => 'Data di ingresso',
- 'Authentication method' => 'Metodo di autenticazzione',
+ 'Last logins' => 'Ultimi accessi',
+ 'Login date' => 'Data di accesso',
+ 'Authentication method' => 'Metodo di autenticazione',
'IP address' => 'Indirizzo IP',
- 'User agent' => 'Navigatore',
+ 'User agent' => 'User agent',
'Persistent connections' => 'Connessioni persistenti',
'No session.' => 'Non esiste sessione.',
'Expiration date' => 'Data di scadenza',
@@ -254,97 +233,89 @@ return array(
'Everybody' => 'Tutti',
'Open' => 'Aperto',
'Closed' => 'Chiuso',
- 'Search' => 'Cercare',
+ 'Search' => 'Cerca',
'Nothing found.' => 'Non si è trovato nulla.',
'Due date' => 'Data di scadenza',
- 'Others formats accepted: %s and %s' => 'Altri formati accettati: %s y %s',
+ 'Others formats accepted: %s and %s' => 'Altri formati accettati: %s e %s',
'Description' => 'Descrizione',
'%d comments' => '%d commenti',
'%d comment' => '%d commento',
- 'Email address invalid' => 'Indirizzo e-mail sbagliato',
- // 'Your external account is not linked anymore to your profile.' => '',
- // 'Unable to unlink your external account.' => '',
- // 'External authentication failed' => '',
- // 'Your external account is linked to your profile successfully.' => '',
+ 'Email address invalid' => 'Indirizzo Email non valido',
+ 'Your external account is not linked anymore to your profile.' => 'Il tuo account esterno non è più collegato al tuo profilo.',
+ 'Unable to unlink your external account.' => 'Impossibile scollegare il tuo account esterno.',
+ 'External authentication failed' => 'Autenticazione esterna fallita',
+ 'Your external account is linked to your profile successfully.' => 'Il tuo account esterno è stato collegato al tuo profilo con successo.',
'Email' => 'E-mail',
- 'Link my Google Account' => 'Collegare il mio Account di Google',
- 'Unlink my Google Account' => 'Scollegare il mio account di Google',
- 'Login with my Google Account' => 'Entra con il mio Account di Google',
- 'Project not found.' => 'progetto non trovato.',
- 'Task removed successfully.' => 'Compito cancellato correttamente.',
- 'Unable to remove this task.' => 'Non si può cancellare questo compito.',
- 'Remove a task' => 'Cancellare un compito',
- 'Do you really want to remove this task: "%s"?' => 'Veramente vuoi cancellare questo compito: "%s"?',
- 'Assign automatically a color based on a category' => 'Assegnare un colore in modo automatico basandosi sulla categoria',
- 'Assign automatically a category based on a color' => 'Assegnare una categoria in modo automatico basandosi sul colore',
- 'Task creation or modification' => 'Creazione o modifica di compito',
+ 'Task removed successfully.' => 'Task cancellato correttamente.',
+ 'Unable to remove this task.' => 'Impossibile cancellare questo task.',
+ 'Remove a task' => 'Cancella un task',
+ 'Do you really want to remove this task: "%s"?' => 'Vuoi davvero cancellare questo task: "%s"?',
+ 'Assign automatically a color based on a category' => 'Assegna un colore in modo automatico basandosi sulla categoria',
+ 'Assign automatically a category based on a color' => 'Assegna una categoria in modo automatico basandosi sul colore',
+ 'Task creation or modification' => 'Creazione o modifica di task',
'Category' => 'Categoria',
'Category:' => 'Categoria:',
'Categories' => 'Categorie',
'Category not found.' => 'Categoria non trovata.',
'Your category have been created successfully.' => 'La tua categoria è stata creata correttamente.',
- 'Unable to create your category.' => 'Non si può creare la tua categoria.',
+ 'Unable to create your category.' => 'Impossibile creare la tua categoria.',
'Your category have been updated successfully.' => 'La tua categoria è stata aggiornata correttamente.',
- 'Unable to update your category.' => 'Non si può aggiornare la tua categoria.',
- 'Remove a category' => 'Cancellare una categoria',
+ 'Unable to update your category.' => 'Impossibile aggiornare la tua categoria.',
+ 'Remove a category' => 'Cancella una categoria',
'Category removed successfully.' => 'Categoria cancellata correttamente.',
- 'Unable to remove this category.' => 'Non si può cancellare questa categoria.',
- 'Category modification for the project "%s"' => 'Modifica di categoria per il progetto "%s"',
- 'Category Name' => 'Nome di categoria',
+ 'Unable to remove this category.' => 'Impossibile cancellare questa categoria.',
+ 'Category modification for the project "%s"' => 'Modifica della categoria per il progetto "%s"',
+ 'Category Name' => 'Nome della categoria',
'Add a new category' => 'Aggiungere una nuova categoria',
- 'Do you really want to remove this category: "%s"?' => 'Vuoi veramente cancellare questa categoria: "%s"?',
+ 'Do you really want to remove this category: "%s"?' => 'Vuoi veramente cancellare la seguente categoria: "%s"?',
'All categories' => 'Tutte le categorie',
'No category' => 'Senza categoria',
'The name is required' => 'Si richiede un nome',
- 'Remove a file' => 'Cancellare un file',
- 'Unable to remove this file.' => 'Non si può cancellare questo file.',
+ 'Remove a file' => 'Cancella un file',
+ 'Unable to remove this file.' => 'Impossibile cancellare questo file.',
'File removed successfully.' => 'File cancellato correttamente.',
- 'Attach a document' => 'Allegare un documento',
+ 'Attach a document' => 'Allega un documento',
'Do you really want to remove this file: "%s"?' => 'Vuoi veramente cancellare questo file: "%s"?',
'Attachments' => 'Allegati',
- 'Edit the task' => 'Modificare il compito',
- 'Edit the description' => 'Modificare la descrizione',
- 'Add a comment' => 'Aggiungere un commento',
- 'Edit a comment' => 'Modificare un commento',
+ 'Edit the task' => 'Modifica il task',
+ 'Edit the description' => 'Modifica la descrizione',
+ 'Add a comment' => 'Aggiungi un commento',
+ 'Edit a comment' => 'Modifica un commento',
'Summary' => 'Sommario',
- 'Time tracking' => 'Time tracking',
+ // 'Time tracking' => '',
'Estimate:' => 'Stimato:',
'Spent:' => 'Trascorso:',
- 'Do you really want to remove this sub-task?' => 'Vuoi veramente cancellare questo sotto-compito?',
+ 'Do you really want to remove this sub-task?' => 'Vuoi veramente cancellare questo sotto-task?',
'Remaining:' => 'Rimangono',
'hours' => 'ore',
'spent' => 'trascorse',
'estimated' => 'stimate',
- 'Sub-Tasks' => 'Sotto-compiti',
- 'Add a sub-task' => 'Aggiungere un sotto-compito',
+ 'Sub-Tasks' => 'Sotto-tasks',
+ 'Add a sub-task' => 'Aggiungi un sotto-task',
'Original estimate' => 'Stima originale',
- 'Create another sub-task' => 'Creare un altro sotto-compito',
- 'Time spent' => 'Tempo Trascorso',
- 'Edit a sub-task' => 'Modificare un sotto-compito',
- 'Remove a sub-task' => 'Cancellare un sotto-compito',
+ 'Create another sub-task' => 'Crea un altro sotto-task',
+ 'Time spent' => 'Tempo trascorso',
+ 'Edit a sub-task' => 'Modifica un sotto-task',
+ 'Remove a sub-task' => 'Cancella un sotto-task',
'The time must be a numeric value' => 'Il tempo deve essere un valore numerico',
'Todo' => 'Da fare',
'In progress' => 'In corso',
- 'Sub-task removed successfully.' => 'Sotto-compito cancellato correttamente.',
- 'Unable to remove this sub-task.' => 'Non si può cancellare questo sotto-compito.',
- 'Sub-task updated successfully.' => 'Sotto-compito aggiornato correttamente.',
- 'Unable to update your sub-task.' => 'Non si può aggiornare il tuo sotto-compito.',
- 'Unable to create your sub-task.' => 'Non si può creare il tuo sotto-compito.',
- 'Sub-task added successfully.' => 'Sotto-compito aggiunto correttamente.',
- 'Maximum size: ' => 'Dimensioni massime',
- 'Unable to upload the file.' => 'Non si può caricare il file.',
- 'Display another project' => 'Mostrare un altro progetto',
- 'Login with my Github Account' => 'Entrare col tuo account di Github',
- 'Link my Github Account' => 'Collegare il mio account Github',
- 'Unlink my Github Account' => 'Scollegare il mio account di Github',
+ 'Sub-task removed successfully.' => 'Sotto-task cancellato correttamente.',
+ 'Unable to remove this sub-task.' => 'Impossibile cancellare questo sotto-task.',
+ 'Sub-task updated successfully.' => 'Sotto-task aggiornato correttamente.',
+ 'Unable to update your sub-task.' => 'Impossibile aggiornare il tuo sotto-task.',
+ 'Unable to create your sub-task.' => 'Impossibile creare il tuo sotto-task.',
+ 'Sub-task added successfully.' => 'Sotto-task aggiunto correttamente.',
+ 'Maximum size: ' => 'Dimensioni massime: ',
+ 'Unable to upload the file.' => 'Impossibile caricare il file.',
+ 'Display another project' => 'Mostra un altro progetto',
'Created by %s' => 'Creato da %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Ultima modifica il %d/%m/%Y alle %H:%M',
- 'Tasks Export' => 'Esportazione di compiti',
- 'Tasks exportation for "%s"' => 'Esportazione di compiti per « %s »',
+ 'Tasks Export' => 'Export dei task',
+ 'Tasks exportation for "%s"' => 'Export dei task per "%s"',
'Start Date' => 'Data d\'inizio',
'End Date' => 'Data di fine',
- 'Execute' => 'Eseguire',
- 'Task Id' => 'Identificatore del compito',
+ 'Execute' => 'Esegui',
+ 'Task Id' => 'Id del task',
'Creator' => 'Creatore',
'Modification date' => 'Data di modifica',
'Completion date' => 'Data di termine',
@@ -352,235 +323,199 @@ return array(
'Project cloned successfully.' => 'Progetto clonato con successo.',
'Unable to clone this project.' => 'Impossibile clonare questo progetto',
'Enable email notifications' => 'Abilita le notifiche via email',
- 'Task position:' => 'Posizione del compito:',
- 'The task #%d have been opened.' => 'Il compito #%d è stato aperto.',
- 'The task #%d have been closed.' => 'Il compito #%d è stato chiuso.',
- 'Sub-task updated' => 'Sotto-compito aggiornato',
+ 'Task position:' => 'Posizione del task:',
+ 'The task #%d have been opened.' => 'Il task #%d è stato aperto.',
+ 'The task #%d have been closed.' => 'Il task #%d è stato chiuso.',
+ 'Sub-task updated' => 'Sotto-task aggiornato',
'Title:' => 'Titolo',
'Status:' => 'Stato',
- 'Assignee:' => 'Assegnatario',
- 'Time tracking:' => 'Gestione del tempo:',
- 'New sub-task' => 'Nuovo sotto-compito',
- 'New attachment added "%s"' => 'Nuovo allegato aggiunto « %s »',
+ 'Assignee:' => 'Assegnatario:',
+ // 'Time tracking:' => '',
+ 'New sub-task' => 'Nuovo sotto-task',
+ 'New attachment added "%s"' => 'Nuovo allegato aggiunto "%s"',
'Comment updated' => 'Commento aggiornato',
- 'New comment posted by %s' => 'Nuovo commento aggiunto da « %s »',
+ 'New comment posted by %s' => 'Nuovo commento aggiunto da "%s"',
'New attachment' => 'Nuovo allegato',
'New comment' => 'Nuovo commento',
- 'New subtask' => 'Nuovo sotto-compito',
- 'Subtask updated' => 'Sotto-compito aggiornato',
- 'Task updated' => 'Compito aggiornato',
- 'Task closed' => 'Compito chiuso',
- 'Task opened' => 'Compito aperto',
+ 'New subtask' => 'Nuovo sotto-task',
+ 'Subtask updated' => 'Sotto-task aggiornato',
+ 'Task updated' => 'Task aggiornato',
+ 'Task closed' => 'Task chiuso',
+ 'Task opened' => 'Task aperto',
'I want to receive notifications only for those projects:' => 'Vorrei ricevere le notifiche solo da questi progetti:',
- 'view the task on Kanboard' => 'vedi il compito su Kanboard',
+ 'view the task on Kanboard' => 'vedi il task su Kanboard',
'Public access' => 'Accesso pubblico',
- 'User management' => 'Gestione utenti',
- 'Active tasks' => 'Compiti attivi',
+ 'Active tasks' => 'Task attivi',
'Disable public access' => 'Disabilita l\'accesso pubblico',
'Enable public access' => 'Abilita l\'accesso pubblico',
'Public access disabled' => 'Accesso pubblico disattivato',
- 'Do you really want to disable this project: "%s"?' => 'Vuoi davvero disabilitare questo progetto: "%s"?',
- 'Do you really want to enable this project: "%s"?' => 'Vuoi davvero abilitare questo progetto: "%s"?',
+ 'Do you really want to disable this project: "%s"?' => 'Vuoi davvero disabilitare il seguente progetto: "%s"?',
+ 'Do you really want to enable this project: "%s"?' => 'Vuoi davvero abilitare il seguente progetto: "%s"?',
'Project activation' => 'Attivazione progetto',
- 'Move the task to another project' => 'Muovi il compito in un altro progetto',
- 'Move to another project' => 'Muovi in un altro progetto',
- 'Do you really want to duplicate this task?' => 'Vuoi davvero duplicare questo compito?',
- 'Duplicate a task' => 'Duplica il compito',
+ 'Move the task to another project' => 'Sposta il task in un altro progetto',
+ 'Move to another project' => 'Sposta in un altro progetto',
+ 'Do you really want to duplicate this task?' => 'Vuoi davvero duplicare questo task?',
+ 'Duplicate a task' => 'Duplica il task',
'External accounts' => 'Account esterni',
'Account type' => 'Tipo di account',
'Local' => 'Locale',
'Remote' => 'Remoto',
'Enabled' => 'Abilitato',
'Disabled' => 'Disabilitato',
- // 'Username:' => '',
+ 'Username:' => 'Nome utente:',
'Name:' => 'Nome:',
// 'Email:' => '',
'Notifications:' => 'Notifiche:',
'Notifications' => 'Notifiche',
- 'Group:' => 'Gruppo',
- 'Regular user' => 'Utente regolare',
'Account type:' => 'Tipo di account',
- 'Edit profile' => 'Modifica il profilo',
+ 'Edit profile' => 'Modifica profilo',
'Change password' => 'Cambia password',
'Password modification' => 'Modifica della password',
'External authentications' => 'Autenticazione esterna',
- 'Google Account' => 'Account Google',
- 'Github Account' => 'Account Github',
'Never connected.' => 'Mai connesso.',
- 'No account linked.' => 'Nessun account collegato.',
- 'Account linked.' => 'Account collegato.',
'No external authentication enabled.' => 'Nessuna autenticazione esterna abilitata.',
'Password modified successfully.' => 'Password modificata con successo.',
'Unable to change the password.' => 'Impossibile cambiare la password.',
- 'Change category for the task "%s"' => 'Cambia categoria per il compito "%s"',
+ 'Change category for the task "%s"' => 'Cambia categoria per il task "%s"',
'Change category' => 'Cambia categoria',
- '%s updated the task %s' => '%s ha aggiornato il compito %s',
- '%s opened the task %s' => '%s ha aperto il compito %s',
- '%s moved the task %s to the position #%d in the column "%s"' => '%s ha spostato il compito %s nella posizione #%d della colonna "%s"',
- '%s moved the task %s to the column "%s"' => '%s ha spostato il compito %s nella colonna "%s"',
- '%s created the task %s' => '%s ha creato il compito %s',
- '%s closed the task %s' => '%s ha chiuso il compito %s',
- '%s created a subtask for the task %s' => '%s ha creato un sotto-compito per il compito %s',
- '%s updated a subtask for the task %s' => '%s ha aggiornato un sotto-compito per il compito %s',
+ '%s updated the task %s' => '%s ha aggiornato il task %s',
+ '%s opened the task %s' => '%s ha aperto il task %s',
+ '%s moved the task %s to the position #%d in the column "%s"' => '%s ha spostato il task %s nella posizione #%d della colonna "%s"',
+ '%s moved the task %s to the column "%s"' => '%s ha spostato il task %s nella colonna "%s"',
+ '%s created the task %s' => '%s ha creato il task %s',
+ '%s closed the task %s' => '%s ha chiuso il task %s',
+ '%s created a subtask for the task %s' => '%s ha creato un sotto-task per il task %s',
+ '%s updated a subtask for the task %s' => '%s ha aggiornato un sotto-task per il task %s',
'Assigned to %s with an estimate of %s/%sh' => 'Assegnato a %s con una stima di %s/%sh',
'Not assigned, estimate of %sh' => 'Non assegnato, stima %sh',
- '%s updated a comment on the task %s' => '%s ha aggiornato un commento del compito %s',
- '%s commented the task %s' => '%s ha commentato il compito %s',
+ '%s updated a comment on the task %s' => '%s ha aggiornato un commento nel task %s',
+ '%s commented the task %s' => '%s ha commentato il task %s',
'%s\'s activity' => 'Attività di %s',
'RSS feed' => 'Feed RSS',
- '%s updated a comment on the task #%d' => '%s ha aggiornato un commento del compito #%d',
- '%s commented on the task #%d' => '%s ha commentato il compito #%d',
- '%s updated a subtask for the task #%d' => '%s ha aggiornato un sotto-compito del compito #%d',
- '%s created a subtask for the task #%d' => '%s ha creato un sotto-compito del compito #%d',
- '%s updated the task #%d' => '%s ha aggiornato il compito #%d',
- '%s created the task #%d' => '%s ha creato il compito #%d',
- '%s closed the task #%d' => '%s ha chiuso il compito #%d',
- '%s open the task #%d' => '%s ha aperto il compito #%d',
- '%s moved the task #%d to the column "%s"' => '%s ha spostato il compito #%d nella colonna "%s"',
- '%s moved the task #%d to the position %d in the column "%s"' => '%s ha spostato il compito #%d nella posizione %d della colonna "%s"',
+ '%s updated a comment on the task #%d' => '%s ha aggiornato un commento del task #%d',
+ '%s commented on the task #%d' => '%s ha commentato il task #%d',
+ '%s updated a subtask for the task #%d' => '%s ha aggiornato un sotto-task del task #%d',
+ '%s created a subtask for the task #%d' => '%s ha creato un sotto-task del task #%d',
+ '%s updated the task #%d' => '%s ha aggiornato il task #%d',
+ '%s created the task #%d' => '%s ha creato il task #%d',
+ '%s closed the task #%d' => '%s ha chiuso il task #%d',
+ '%s open the task #%d' => '%s ha aperto il task #%d',
+ '%s moved the task #%d to the column "%s"' => '%s ha spostato il task #%d nella colonna "%s"',
+ '%s moved the task #%d to the position %d in the column "%s"' => '%s ha spostato il task #%d nella posizione %d della colonna "%s"',
'Activity' => 'Attività',
'Default values are "%s"' => 'Valori di default "%s"',
'Default columns for new projects (Comma-separated)' => 'Colonne di default per i nuovi progetti (Separati da virgola)',
- 'Task assignee change' => 'Cambiare l\'assegnatario del compito',
- '%s change the assignee of the task #%d to %s' => '%s dai l\'assegnazione del compito #%d a %s',
- '%s changed the assignee of the task %s to %s' => '%s ha cambiato l\'assegnatario del compito %s a %s',
+ 'Task assignee change' => 'Cambia l\'assegnatario del task',
+ '%s change the assignee of the task #%d to %s' => '%s dai l\'assegnazione del task #%d a %s',
+ '%s changed the assignee of the task %s to %s' => '%s ha cambiato l\'assegnatario del task %s a %s',
'New password for the user "%s"' => 'Nuova password per l\'utente "%s"',
'Choose an event' => 'Scegli un evento',
- 'Github commit received' => 'Commit di Github ricevuto',
- 'Github issue opened' => 'Issue di Github ricevuto',
- 'Github issue closed' => 'Issue di Github chiusa',
- 'Github issue reopened' => 'Issue di Github riaperta',
- 'Github issue assignee change' => 'Assegnatario dell\'issue di Github cambiato',
- 'Github issue label change' => 'Etichetta dell\'issue di Github cambiata',
- 'Create a task from an external provider' => 'Crea un compito da un provider esterno',
+ 'Create a task from an external provider' => 'Crea un task da un provider esterno',
'Change the assignee based on an external username' => 'Cambia l\'assegnatario basandosi su un username esterno',
'Change the category based on an external label' => 'Cambia la categoria basandosi su un\'etichetta esterna',
- 'Reference' => 'Referenza',
- 'Reference: %s' => 'Referenza :%s',
+ 'Reference' => 'Riferimento',
'Label' => 'Etichetta',
// 'Database' => '',
- 'About' => 'Info',
+ 'About' => 'Informazioni',
'Database driver:' => 'Driver per Database',
'Board settings' => 'Impostazioni bacheca',
'URL and token' => 'URL e token',
'Webhook settings' => 'Impostazione Webhook',
- 'URL for task creation:' => 'URL per la creazione dei compiti:',
+ 'URL for task creation:' => 'URL per la creazione dei tasks:',
'Reset token' => 'Rigenera il token',
'API endpoint:' => 'Endpoint dell\'API:',
'Refresh interval for private board' => 'Intervallo di refresh per le bacheche private',
'Refresh interval for public board' => 'Intervallo di refresh per le bacheche pubbliche',
- 'Task highlight period' => 'Periodo di evidenza per il compito',
- 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Periodo (in secondi) per considerare un compito come modificato recentemente (0 per disabilitare, 2 giorni di default)',
+ 'Task highlight period' => 'Periodo di evidenza per il task',
+ 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Periodo (in secondi) per considerare un task come modificato recentemente (0 per disabilitare, 2 giorni di default)',
'Frequency in second (60 seconds by default)' => 'Frequenza in secondi (60 secondi di default)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frequenza in secondi (0 secondi di default)',
'Application URL' => 'URL dell\'applicazione',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Esempio: http://example.kanboard.net/ (usato dalle notifiche email)',
- 'Token regenerated.' => 'Token rigenerato',
+ 'Token regenerated.' => 'Token rigenerato.',
'Date format' => 'Formato data',
'ISO format is always accepted, example: "%s" and "%s"' => 'Il formato ISO è sempre accettato, esempio: "%s" e "%s"',
'New private project' => 'Nuovo progetto privato',
'This project is private' => 'Questo progetto è privato',
- 'Type here to create a new sub-task' => 'Scrivi qui per creare un sotto-compito',
+ 'Type here to create a new sub-task' => 'Scrivi qui per creare un sotto-task',
'Add' => 'Aggiungi',
- 'Estimated time: %s hours' => 'Tempo stimato: %s ore',
- 'Time spent: %s hours' => 'Tempo trascorso: %s ore',
- 'Started on %B %e, %Y' => 'Avviato il %B %e, %Y',
'Start date' => 'Data di inizio',
'Time estimated' => 'Tempo stimato',
'There is nothing assigned to you.' => 'Non c\'è nulla assegnato a te.',
- 'My tasks' => 'I miei compiti',
+ 'My tasks' => 'I miei task',
'Activity stream' => 'Flusso di attività',
'Dashboard' => 'Bacheca',
'Confirmation' => 'Conferma',
'Allow everybody to access to this project' => 'Abilita tutti ad accedere a questo progetto',
- 'Everybody have access to this project.' => 'Tutti hanno accesso a questo progetto',
+ 'Everybody have access to this project.' => 'Tutti hanno accesso a questo progetto.',
// 'Webhooks' => '',
// 'API' => '',
- 'Github webhooks' => 'Webhooks di Github',
- 'Help on Github webhooks' => 'Guida ai Webhooks di Github',
'Create a comment from an external provider' => 'Crea un commit da un provider esterno',
- 'Github issue comment created' => 'Commento ad un Issue di Github creato',
- 'Project management' => 'Gestione del progetto',
+ 'Project management' => 'Gestione progetti',
'My projects' => 'I miei progetti',
'Columns' => 'Colonne',
- 'Task' => 'Compito',
- 'Your are not member of any project.' => 'Non sei membro di alcun progetto',
+ 'Task' => 'Task',
+ 'Your are not member of any project.' => 'Non sei membro di alcun progetto.',
'Percentage' => 'Percentuale',
- 'Number of tasks' => 'Numero di compiti',
- 'Task distribution' => 'Distribuzione dei compiti',
+ 'Number of tasks' => 'Numero di task',
+ 'Task distribution' => 'Distribuzione dei task',
'Reportings' => 'Rapporti',
- 'Task repartition for "%s"' => 'Ripartizione compiti per "%s"',
+ 'Task repartition for "%s"' => 'Ripartizione task per "%s"',
// 'Analytics' => '',
- 'Subtask' => 'Sotto-compiti',
- 'My subtasks' => 'I miei sotto-compiti',
+ 'Subtask' => 'Sotto-task',
+ 'My subtasks' => 'I miei sotto-task',
'User repartition' => 'Ripartizione per utente',
'User repartition for "%s"' => 'Ripartizione utente per "%s"',
'Clone this project' => 'Clona questo progetto',
'Column removed successfully.' => 'Colonna rimossa con successo',
- 'Github Issue' => 'Issue di Github',
'Not enough data to show the graph.' => 'Non ci sono abbastanza dati per visualizzare il grafico.',
'Previous' => 'Precendete',
'The id must be an integer' => 'L\'id deve essere un intero',
'The project id must be an integer' => 'L\'id del progetto deve essere un intero',
'The status must be an integer' => 'Lo status deve essere un intero',
- 'The subtask id is required' => 'L\'id del sotto-compito è necessario',
- 'The subtask id must be an integer' => 'L\'id del sotto-compito deve essere un intero',
- 'The task id is required' => 'Richiesto l\'id del compito',
- 'The task id must be an integer' => 'L\'id del compito deve essere un intero',
+ 'The subtask id is required' => 'L\'id del sotto-task è necessario',
+ 'The subtask id must be an integer' => 'L\'id del sotto-task deve essere un intero',
+ 'The task id is required' => 'Richiesto l\'id del task',
+ 'The task id must be an integer' => 'L\'id del task deve essere un intero',
'The user id must be an integer' => 'L\'id dell\'utente deve essere un intero',
'This value is required' => 'Questo valore è necessario',
'This value must be numeric' => 'Questo valore deve essere numerico',
- 'Unable to create this task.' => 'Impossibile creare questo compito',
+ 'Unable to create this task.' => 'Impossibile creare questo task',
'Cumulative flow diagram' => 'Diagramma di flusso cumulativo',
'Cumulative flow diagram for "%s"' => 'Diagramma di flusso comulativo per "%s"',
'Daily project summary' => 'Sommario giornaliero del progetto',
- 'Daily project summary export' => 'Esportazione del sommario giornaliero del progetto',
- 'Daily project summary export for "%s"' => 'Esportazione del sommario giornaliero del progetto per "%s"',
+ 'Daily project summary export' => 'Export del sommario giornaliero del progetto',
+ 'Daily project summary export for "%s"' => 'Export del sommario giornaliero del progetto per "%s"',
'Exports' => 'Esporta',
- 'This export contains the number of tasks per column grouped per day.' => 'Questo export contiene il numero di compiti per colonna raggruppati per giorno',
+ 'This export contains the number of tasks per column grouped per day.' => 'Questo export contiene il numero di task per colonna raggruppati per giorno',
'Nothing to preview...' => 'Nessuna anteprima...',
'Preview' => 'Anteprima',
'Write' => 'Scrivi',
'Active swimlanes' => 'Corsie attive',
'Add a new swimlane' => 'Aggiungi una corsia',
- 'Change default swimlane' => 'Cambia la corsia di default',
- 'Default swimlane' => 'Corsia di default',
- 'Do you really want to remove this swimlane: "%s"?' => 'Vuoi davvero rimuovere questa corsia: "%s"?',
+ 'Change default swimlane' => 'Cambia la corsia predefinita',
+ 'Default swimlane' => 'Corsia predefinita',
+ 'Do you really want to remove this swimlane: "%s"?' => 'Vuoi davvero rimuovere la seguente corsia: "%s"?',
'Inactive swimlanes' => 'Corsie inattive',
- 'Set project manager' => 'Imposta un manager del progetto',
- 'Set project member' => 'Imposta un membro del progetto',
'Remove a swimlane' => 'Rimuovi una corsia',
- 'Rename' => 'Rinomina',
- 'Show default swimlane' => 'Mostra le corsie di default',
+ 'Show default swimlane' => 'Mostra la corsia predefinita',
'Swimlane modification for the project "%s"' => 'Modifica corsia per il progetto "%s"',
- 'Swimlane not found.' => 'Corsia non trovata',
- 'Swimlane removed successfully.' => 'Corsia rimossa con successo',
+ 'Swimlane not found.' => 'Corsia non trovata.',
+ 'Swimlane removed successfully.' => 'Corsia rimossa con successo.',
'Swimlanes' => 'Corsie',
- 'Swimlane updated successfully.' => 'Corsia aggiornata con successo',
- 'The default swimlane have been updated successfully.' => 'La corsia di default è stata aggiornata con successo.',
- 'Unable to create your swimlane.' => 'Impossibile creare la sua corsia.',
+ 'Swimlane updated successfully.' => 'Corsia aggiornata con successo.',
+ 'The default swimlane have been updated successfully.' => 'La corsia predefinita è stata aggiornata con successo.',
'Unable to remove this swimlane.' => 'Impossibile rimuovere questa corsia.',
'Unable to update this swimlane.' => 'Impossibile aggiornare questa corsia.',
- 'Your swimlane have been created successfully.' => 'La sua corsia è stata creata con successo',
- 'Example: "Bug, Feature Request, Improvement"' => 'Esempio: "Bug, Richiesta di Funzioni, Migliorie"',
+ 'Your swimlane have been created successfully.' => 'La tua corsia è stata creata con successo',
+ 'Example: "Bug, Feature Request, Improvement"' => 'Esempi: "Bug, Richiesta di Funzioni, Migliorie"',
'Default categories for new projects (Comma-separated)' => 'Categorie di default per i progetti (Separati da virgola)',
- 'Gitlab commit received' => 'Commit ricevuto da Gitlab',
- 'Gitlab issue opened' => 'Issue di Gitlab aperta',
- 'Gitlab issue closed' => 'Issue di Gitlab chiusa',
- 'Gitlab webhooks' => 'Webhooks di Gitlab',
- 'Help on Gitlab webhooks' => 'Guida ai Webhooks di Gitlab',
'Integrations' => 'Integrazioni',
'Integration with third-party services' => 'Integrazione con servizi di terze parti',
- 'Role for this project' => 'Ruolo per questo progetto',
- 'Project manager' => 'Manager del progetto',
- 'Project member' => 'Membro del progetto',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Un manager del progetto può cambiare le impostazioni del progetto ed avere più privilegi di un utente standard.',
- 'Gitlab Issue' => 'Issue di Gitlab',
- 'Subtask Id' => 'Id del sotto-compito',
- 'Subtasks' => 'Sotto-compiti',
- 'Subtasks Export' => 'Esporta sotto-compiti',
- 'Subtasks exportation for "%s"' => 'Esportazione dei sotto-compiti per "%s"',
- 'Task Title' => 'Titolo del compito',
+ 'Subtask Id' => 'Id del sotto-task',
+ 'Subtasks' => 'Sotto-task',
+ 'Subtasks Export' => 'Esporta i sotto-task',
+ 'Subtasks exportation for "%s"' => 'Export dei sotto-task per "%s"',
+ 'Task Title' => 'Titolo del task',
'Untitled' => 'Senza titolo',
'Application default' => 'Default dell\'applicazione',
'Language:' => 'Lingua',
@@ -594,44 +529,41 @@ return array(
'Moved to column %s' => 'Spostato sulla colonna "%s"',
'Change description' => 'Cambia descrizione',
'User dashboard' => 'Bacheca utente',
- 'Allow only one subtask in progress at the same time for a user' => 'Permetti un solo sotto-compito in progresso per utente nello stesso tempo',
+ 'Allow only one subtask in progress at the same time for a user' => 'Permetti un solo sotto-task in corso per utente alla volta',
'Edit column "%s"' => 'Modifica la colonna "%s"',
- 'Select the new status of the subtask: "%s"' => 'Selziona il nuovo status per il sotto-compito: "%s"',
- 'Subtask timesheet' => 'Timesheet del sotto-compito',
+ 'Select the new status of the subtask: "%s"' => 'Seleziona il nuovo status per il sotto-task: "%s"',
+ 'Subtask timesheet' => 'Timesheet del sotto-task',
'There is nothing to show.' => 'Nulla da mostrare.',
- 'Time Tracking' => 'Gestione del tempo',
- 'You already have one subtask in progress' => 'Hai già un sotto-compito in progresso',
+ // 'Time Tracking' => '',
+ 'You already have one subtask in progress' => 'Hai già un sotto-task in corso',
'Which parts of the project do you want to duplicate?' => 'Quali parti del progetto vuoi duplicare?',
- // 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Commit ricevuto da Bitbucket',
- 'Bitbucket webhooks' => 'Webhooks di Bitbucket',
- 'Help on Bitbucket webhooks' => 'Guida ai Webhooks di Bitbucket',
+ 'Disallow login form' => 'Disabilita il form di login',
'Start' => 'Inizio',
'End' => 'Fine',
- 'Task age in days' => 'Anzianità del compito in giorni',
+ 'Task age in days' => 'Anzianità del task in giorni',
'Days in this column' => 'Giorni in questa colonna',
// '%dd' => '',
- 'Add a link' => 'Aggiungi un link',
- 'Add a new link' => 'Aggiungi un nuovo link',
- 'Do you really want to remove this link: "%s"?' => 'Vuoi davvero rimuovere questo link: "%s"?',
- 'Do you really want to remove this link with task #%d?' => 'Vuoi davvero rimuovere questo link dal compito #%d?',
+ 'Add a link' => 'Aggiungi una relazione',
+ 'Add a new link' => 'Aggiungi una nuova relazione',
+ 'Do you really want to remove this link: "%s"?' => 'Vuoi davvero rimuovere la seguente relazione: "%s"?',
+ 'Do you really want to remove this link with task #%d?' => 'Vuoi davvero rimuovere questa relazione dal task #%d?',
'Field required' => 'Campo necessario',
- 'Link added successfully.' => 'Link aggiunto con successo.',
- 'Link updated successfully.' => 'Linka aggiornato con successo.',
- 'Link removed successfully.' => 'Link rimosso con successo.',
- 'Link labels' => 'Etichette dei link',
- 'Link modification' => 'Modifica link',
- 'Links' => 'Link',
- 'Link settings' => 'Impostazioni link',
- // 'Opposite label' => '',
- 'Remove a link' => 'Rimuovi un link',
- 'Task\'s links' => 'Link del compito',
+ 'Link added successfully.' => 'Relazione aggiunta con successo.',
+ 'Link updated successfully.' => 'Relazione aggiornata con successo.',
+ 'Link removed successfully.' => 'Relazione rimosso con successo.',
+ 'Link labels' => 'Etichette delle relazioni',
+ 'Link modification' => 'Modifica relazione',
+ 'Links' => 'Relazioni',
+ 'Link settings' => 'Impostazioni relazioni',
+ 'Opposite label' => 'Etichetta contraria',
+ 'Remove a link' => 'Rimuovi una relazione',
+ 'Task\'s links' => 'Relazioni del task',
'The labels must be different' => 'Le etichette devono essere diverse',
- 'There is no link.' => 'Non c\'è alcun link',
- 'This label must be unique' => 'Questa etichetta deve essere unica',
- 'Unable to create your link.' => 'Impossibile creare il suo link.',
- 'Unable to update your link.' => 'Impossibile aggiornare il suo link.',
- 'Unable to remove this link.' => 'Impossibile rimuovere il suo link.',
+ 'There is no link.' => 'Nessuna relazione presente.',
+ 'This label must be unique' => 'Questa etichetta deve essere univoca',
+ 'Unable to create your link.' => 'Impossibile creare la relazione.',
+ 'Unable to update your link.' => 'Impossibile aggiornare la relazione.',
+ 'Unable to remove this link.' => 'Impossibile rimuovere la relazione.',
'relates to' => 'si riferisce a',
'blocks' => 'blocca',
'is blocked by' => 'è bloccato da',
@@ -643,32 +575,28 @@ return array(
'is a milestone of' => 'è una milestone di',
'fixes' => 'sistema',
'is fixed by' => 'è sistemato da',
- 'This task' => 'Questo compito',
+ 'This task' => 'Questo task',
// '<1h' => '',
// '%dh' => '',
- // '%b %e' => '',
- 'Expand tasks' => 'Espandi i compiti',
- 'Collapse tasks' => 'Minimizza i compiti',
- 'Expand/collapse tasks' => 'Espandi/Minimizza compiti',
- 'Close dialog box' => 'Chiudi dialog box',
+ 'Expand tasks' => 'Espandi i task',
+ 'Collapse tasks' => 'Minimizza i task',
+ 'Expand/collapse tasks' => 'Espandi/minimizza i task',
+ 'Close dialog box' => 'Chiudi la finestra di dialogo',
'Submit a form' => 'Invia i dati',
'Board view' => 'Vista bacheca',
'Keyboard shortcuts' => 'Scorciatoie da tastiera',
- 'Open board switcher' => 'Apri il selezionatore di bacheche',
+ 'Open board switcher' => 'Apri il selettore di bacheche',
'Application' => 'Applicazione',
- 'since %B %e, %Y at %k:%M %p' => 'dal %B %e, %Y alle %k:%M %p',
'Compact view' => 'Vista compatta',
'Horizontal scrolling' => 'Scrolling orizzontale',
'Compact/wide view' => 'Vista compatta/estesa',
'No results match:' => 'Nessun risultato trovato:',
'Currency' => 'Valuta',
- // 'Files' => '',
- 'Images' => 'Immagini',
'Private project' => 'Progetto privato',
'AUD - Australian Dollar' => 'AUD - Dollari Australiani',
'CAD - Canadian Dollar' => 'CAD - Dollari Canadesi',
'CHF - Swiss Francs' => 'CHF - Franchi Svizzeri',
- 'Custom Stylesheet' => 'CSS personalizzato',
+ 'Custom Stylesheet' => 'Foglio di stile personalizzato',
// 'download' => '',
// 'EUR - Euro' => '',
'GBP - British Pound' => 'GBP - Pound Inglesi',
@@ -678,15 +606,15 @@ return array(
'RSD - Serbian dinar' => 'RSD - Dinar Serbi',
'USD - US Dollar' => 'USD - Dollari Americani',
'Destination column' => 'Colonna destinazione',
- 'Move the task to another column when assigned to a user' => 'Sposta il compito in un\'altra colonna quando viene assegnato ad un utente',
- 'Move the task to another column when assignee is cleared' => 'Sposta il compito in un\'altra colonna quando l\'assegnatario cancellato',
+ 'Move the task to another column when assigned to a user' => 'Sposta il task in un\'altra colonna quando viene assegnato ad un utente',
+ 'Move the task to another column when assignee is cleared' => 'Sposta il task in un\'altra colonna quando l\'assegnatario viene cancellato',
'Source column' => 'Colonna sorgente',
'Transitions' => 'Transizioni',
'Executer' => 'Esecutore',
'Time spent in the column' => 'Tempo trascorso nella colonna',
- 'Task transitions' => 'Transizioni del compito',
- 'Task transitions export' => 'Esporta le transizioni del compito',
- 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Questo report contiene tutti i movimenti di colonna per ogni compito con le date, l\'utente ed il tempo trascorso per ogni transizione',
+ 'Task transitions' => 'Transizioni dei task',
+ 'Task transitions export' => 'Export delle transizioni dei task',
+ 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Questo report contiene tutti gli spotamenti di colonna per ogni task con le date, l\'utente ed il tempo trascorso per ogni transizione',
'Currency rates' => 'Tassi di cambio',
'Rate' => 'Cambio',
'Change reference currency' => 'Cambia la valuta di riferimento',
@@ -695,373 +623,529 @@ return array(
'The currency rate have been added successfully.' => 'Il tasso di cambio è stato aggiunto con successo.',
'Unable to add this currency rate.' => 'Impossibile aggiungere questo tasso di cambio.',
'Webhook URL' => 'URL Webhook',
- '%s remove the assignee of the task %s' => '%s rimuove l\'assegnatario del compito %s',
+ '%s remove the assignee of the task %s' => '%s rimuove l\'assegnatario del task %s',
'Enable Gravatar images' => 'Abilita immagini Gravatar',
'Information' => 'Informazioni',
- 'Check two factor authentication code' => 'Controlla il codice di autenticazione a due fattori',
- 'The two factor authentication code is not valid.' => 'Il codice di autenticazione a due fattori non è valido',
- 'The two factor authentication code is valid.' => 'Il codice di autenticazione a due fattori è valido',
+ 'Check two factor authentication code' => 'Controlla il codice di autenticazione "two-factor"',
+ 'The two factor authentication code is not valid.' => 'Il codice di autenticazione "two-factor" non è valido',
+ 'The two factor authentication code is valid.' => 'Il codice di autenticazione "two-factor" è valido',
'Code' => 'Codice',
- 'Two factor authentication' => 'Autenticazione a due fattori',
- 'Enable/disable two factor authentication' => 'Abilita/disabilita autenticazione a due fattori',
+ 'Two factor authentication' => 'Autenticazione "two-factor"',
'This QR code contains the key URI: ' => 'Questo QR code contiene l\'URI: ',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Salva la chiave privata nel tuo software TOTP (per esempio Google Authenticator oppure FreeOTP).',
'Check my code' => 'Controlla il mio codice',
'Secret key: ' => 'Chiave privata:',
'Test your device' => 'Testa il tuo dispositivo',
- 'Assign a color when the task is moved to a specific column' => 'Assegna un colore quando il compito viene spostato in una colonna specifica',
- // '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %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' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
- // 'Identifier' => '',
- // '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' => '',
- // '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)' => '',
- // '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.' => '',
- // 'User that will receive the email' => '',
- // 'Email subject' => '',
- // 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
- // 'Add a comment log when moving the task between columns' => '',
- // 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
- // 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
- // 'Column change' => '',
- // 'Position change' => '',
- // 'Swimlane change' => '',
- // 'Assignee change' => '',
- // '[%s] Overdue tasks' => '',
- // 'Notification' => '',
- // '%s moved the task #%d to the first swimlane' => '',
- // '%s moved the task #%d to the swimlane "%s"' => '',
- // 'Swimlane' => '',
+ 'Assign a color when the task is moved to a specific column' => 'Assegna un colore quando il task viene spostato in una colonna specifica',
+ '%s via Kanboard' => '%s tramite Kanboard',
+ 'Burndown chart for "%s"' => 'Grafico Burndown per "%s"',
+ 'Burndown chart' => 'Grafico Burndown',
+ 'This chart show the task complexity over the time (Work Remaining).' => 'Questo grafico mostra la complessità dei task nel tempo (Lavoro residuo).',
+ 'Screenshot taken %s' => 'Schermata catturata %s',
+ 'Add a screenshot' => 'Aggiungi una schermata',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Cattura una schermata e premi CTRL+V o ⌘+V per incollarla qui.',
+ 'Screenshot uploaded successfully.' => 'Schermata caricata correttamente.',
+ 'SEK - Swedish Krona' => 'SEK - Corona svedese',
+ 'Identifier' => 'Identificatore',
+ 'Disable two factor authentication' => 'Disabilita l\'autenticazione "two-factor"',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Vuoi davvero disabilitare l\'autenticazione "two-factor" per questo utente: "%s"?',
+ 'Edit link' => 'Modifica relazione',
+ 'Start to type task title...' => 'Inzia a digitare il titolo di un task...',
+ 'A task cannot be linked to itself' => 'Un task non può essere correlato a se stesso',
+ 'The exact same link already exists' => 'La stessa relazione risulta già esistente',
+ 'Recurrent task is scheduled to be generated' => 'Il task ricorrente è pianificato per essere generato',
+ 'Score' => 'Punteggio',
+ 'The identifier must be unique' => 'L\'identificatore deve essere univoco',
+ 'This linked task id doesn\'t exists' => 'L\'id del task correlato non esiste',
+ 'This value must be alphanumeric' => 'Questo valore deve essere alfanumerico',
+ 'Edit recurrence' => 'Modifica ricorrenza',
+ 'Generate recurrent task' => 'Genera task ricorrente',
+ 'Trigger to generate recurrent task' => 'Trigger per generare il task ricorrente',
+ 'Factor to calculate new due date' => 'Fattore numerico per calcolare la nuova data di scadenza',
+ 'Timeframe to calculate new due date' => 'Lasso temporale per calcolare la nuova data di scadenza',
+ 'Base date to calculate new due date' => 'Data di partenza per calcolare la nuova data di scadenza',
+ 'Action date' => 'Data di azione (action date)',
+ 'Base date to calculate new due date: ' => 'Data di base per calcolare la nuova data di scadenza: ',
+ 'This task has created this child task: ' => 'Questo task ha creato il seguente task figlio: ',
+ 'Day(s)' => 'Giorno/i',
+ 'Existing due date' => 'Data di scadenza esistente',
+ 'Factor to calculate new due date: ' => 'Fattore numerico per calcolare la nuova data di scadenza: ',
+ 'Month(s)' => 'Mese/i',
+ 'Recurrence' => 'Ricorrenza',
+ 'This task has been created by: ' => 'Questo task è stato creato da: ',
+ 'Recurrent task has been generated:' => 'Il task ricorrente è stato generato:',
+ 'Timeframe to calculate new due date: ' => 'Lasso temporale per calcolare la nuova data di scadenza: ',
+ 'Trigger to generate recurrent task: ' => 'Trigger per generare il task ricorrente: ',
+ 'When task is closed' => 'Quando un task è chiuso',
+ 'When task is moved from first column' => 'Quando un task è spostato dalla prima colonna',
+ 'When task is moved to last column' => 'Quando un task è spostato nell\'ultima colonna',
+ 'Year(s)' => 'Anno/i',
+ 'Calendar settings' => 'Impostazioni del calendario',
+ 'Project calendar view' => 'Vista di progetto a calendario',
+ 'Project settings' => 'Impostazioni di progetto',
+ 'Show subtasks based on the time tracking' => 'Mostra i sotto-task in base al time tracking',
+ 'Show tasks based on the creation date' => 'Mostra i task in base alla data di creazione',
+ 'Show tasks based on the start date' => 'Mostra i task in base alla data di inzio',
+ 'Subtasks time tracking' => 'Time tracking per i sotto-task',
+ 'User calendar view' => 'Vista utente a calendario',
+ 'Automatically update the start date' => 'Aggiorna automaticamente la data di inzio',
+ 'iCal feed' => 'feed iCal',
+ 'Preferences' => 'Preferenze',
+ 'Security' => 'Sicurezza',
+ 'Two factor authentication disabled' => 'Two factor authentication disabilitata',
+ 'Two factor authentication enabled' => 'Two factor authentication abilitata',
+ 'Unable to update this user.' => 'Impossibile aggiornare questo utente.',
+ 'There is no user management for private projects.' => 'Non è prevista la gestione di utenti per i progetti privati.',
+ 'User that will receive the email' => 'Utente che riceverà l\'email',
+ 'Email subject' => 'Soggetto dell\'email',
+ 'Date' => 'Data',
+ 'Add a comment log when moving the task between columns' => 'Aggiungi un log quando si sposta un task tra colonne',
+ 'Move the task to another column when the category is changed' => 'Sposta il task in un\'altra colonna quando la categoria viene modificata',
+ 'Send a task by email to someone' => 'Invia un task via email a qualcuno',
+ 'Reopen a task' => 'Riapri un task',
+ 'Column change' => 'Cambio di colonna',
+ 'Position change' => 'Cambio di posizione',
+ 'Swimlane change' => 'Cambio di corsia',
+ 'Assignee change' => 'Cambio assegnatario',
+ '[%s] Overdue tasks' => '[%s] Task scaduti',
+ 'Notification' => 'Notifica',
+ '%s moved the task #%d to the first swimlane' => '%s ha spostato il task #%d nella prima corsia',
+ '%s moved the task #%d to the swimlane "%s"' => '%s ha spostato il task #%d nella corsia "%s"',
+ 'Swimlane' => 'Corsia',
// 'Gravatar' => '',
- // '%s moved the task %s to the first swimlane' => '',
- // '%s moved the task %s to the swimlane "%s"' => '',
- // 'This report contains all subtasks information for the given date range.' => '',
- // 'This report contains all tasks information for the given date range.' => '',
- // 'Project activities for %s' => '',
- // 'view the board on Kanboard' => '',
- // 'The task have been moved to the first swimlane' => '',
- // 'The task have been moved to another swimlane:' => '',
- // 'Overdue tasks for the project "%s"' => '',
- // 'New title: %s' => '',
- // 'The task is not assigned anymore' => '',
- // 'New assignee: %s' => '',
- // 'There is no category now' => '',
- // 'New category: %s' => '',
- // 'New color: %s' => '',
- // 'New complexity: %d' => '',
- // 'The due date have been removed' => '',
- // 'There is no description anymore' => '',
- // 'Recurrence settings have been modified' => '',
- // 'Time spent changed: %sh' => '',
- // 'Time estimated changed: %sh' => '',
- // 'The field "%s" have been updated' => '',
- // 'The description have been modified' => '',
- // 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
- // 'I want to receive notifications for:' => '',
- // 'All tasks' => '',
- // 'Only for tasks assigned to me' => '',
- // 'Only for tasks created by me' => '',
- // 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
+ '%s moved the task %s to the first swimlane' => '%s ha spostato il task %s nella prima corsia',
+ '%s moved the task %s to the swimlane "%s"' => '%s ha spostato il task %s nella corsia %s',
+ 'This report contains all subtasks information for the given date range.' => 'Questo report contiente tutte le informazioni sui sotto-task nell\'arco temporale indicato.',
+ 'This report contains all tasks information for the given date range.' => 'Questo report contiente tutte le informazioni sui task nell\'arco temporale indicato.',
+ 'Project activities for %s' => 'Attività di progetto per %s',
+ 'view the board on Kanboard' => 'guarda la bacheca su Kanboard',
+ 'The task have been moved to the first swimlane' => 'Il task è stato spostato nella prima corsia',
+ 'The task have been moved to another swimlane:' => 'Il task è stato spostato in un\'altra corsia:',
+ 'Overdue tasks for the project "%s"' => 'Task scaduti per il progetto "%s"',
+ 'New title: %s' => 'Nuovo titolo: %s',
+ 'The task is not assigned anymore' => 'Il task non è più assegnato a nessuno',
+ 'New assignee: %s' => 'Nuovo assegnatario: %s',
+ 'There is no category now' => 'Non è presente più nessuna categoria',
+ 'New category: %s' => 'Nuova categoria: %s',
+ 'New color: %s' => 'Nuovo colorei: %s',
+ 'New complexity: %d' => 'Nuova complessità: %d',
+ 'The due date have been removed' => 'La data di scadenza è stata rimossa',
+ 'There is no description anymore' => 'Non è presente più alcuna descrizione.',
+ 'Recurrence settings have been modified' => 'Le impostazioni di ricorrenza sono state modificate',
+ 'Time spent changed: %sh' => 'Tempo trascorso modificato: %sh',
+ 'Time estimated changed: %sh' => 'Tempo stimato modificato: %sh',
+ 'The field "%s" have been updated' => 'Il campo %s è stato aggiornato',
+ 'The description have been modified' => 'La descrizione è stata modificata',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'Vuoi veramente chiudere il task "%s" e i relativi sotto-task?',
+ 'I want to receive notifications for:' => 'Voglio ricevere le notifiche per:',
+ 'All tasks' => 'Tutti i task',
+ 'Only for tasks assigned to me' => 'Solo per i task assegnati a me',
+ 'Only for tasks created by me' => 'Solo per i task creati da me',
+ 'Only for tasks created by me and assigned to me' => 'Solo per i task creati da me e assegnati a me',
// '%%Y-%%m-%%d' => '',
- // 'Total for all columns' => '',
- // 'You need at least 2 days of data to show the chart.' => '',
+ 'Total for all columns' => 'Totale per tutte le colonne',
+ 'You need at least 2 days of data to show the chart.' => 'Hai bisogno di almeno 2 giorni di dati per mostrare il grafico.',
// '<15m' => '',
// '<30m' => '',
- // 'Stop timer' => '',
- // 'Start timer' => '',
- // 'Add project member' => '',
- // 'Enable notifications' => '',
- // 'My activity stream' => '',
- // 'My calendar' => '',
- // 'Search tasks' => '',
- // 'Back to the calendar' => '',
- // 'Filters' => '',
- // 'Reset filters' => '',
- // 'My tasks due tomorrow' => '',
- // 'Tasks due today' => '',
- // 'Tasks due tomorrow' => '',
- // 'Tasks due yesterday' => '',
- // 'Closed tasks' => '',
- // 'Open tasks' => '',
- // 'Not assigned' => '',
- // 'View advanced search syntax' => '',
- // 'Overview' => '',
- // '%b %e %Y' => '',
+ 'Stop timer' => 'Ferma il timer',
+ 'Start timer' => 'Avvia il timer',
+ 'Add project member' => 'Aggiungi un membro di progetto',
+ 'Enable notifications' => 'Abilita notifiche',
+ 'My activity stream' => 'Il mio flusso di attività',
+ 'My calendar' => 'Il mio calendario',
+ 'Search tasks' => 'Ricerca task',
+ 'Back to the calendar' => 'Torna al calendario',
+ 'Filters' => 'Filtri',
+ 'Reset filters' => 'Annulla filtri',
+ 'My tasks due tomorrow' => 'I miei task da completare per domani',
+ 'Tasks due today' => 'Task da completare oggi',
+ 'Tasks due tomorrow' => 'Task da completare per domani',
+ 'Tasks due yesterday' => 'Task da completare ieri',
+ 'Closed tasks' => 'Task chiusi',
+ 'Open tasks' => 'Task aperti',
+ 'Not assigned' => 'Non assegnato',
+ 'View advanced search syntax' => 'Visualizza la sintassi di ricerca avanzata',
+ 'Overview' => 'Panoramica',
// 'Board/Calendar/List view' => '',
- // 'Switch to the board view' => '',
- // 'Switch to the calendar view' => '',
- // 'Switch to the list view' => '',
- // 'Go to the search/filter box' => '',
- // 'There is no activity yet.' => '',
- // 'No tasks found.' => '',
- // 'Keyboard shortcut: "%s"' => '',
- // 'List' => '',
- // 'Filter' => '',
- // 'Advanced search' => '',
- // 'Example of query: ' => '',
- // 'Search by project: ' => '',
- // 'Search by column: ' => '',
- // 'Search by assignee: ' => '',
- // 'Search by color: ' => '',
- // 'Search by category: ' => '',
- // 'Search by description: ' => '',
- // 'Search by due date: ' => '',
- // 'Lead and Cycle time for "%s"' => '',
- // 'Average time spent into each column for "%s"' => '',
- // 'Average time spent into each column' => '',
- // 'Average time spent' => '',
- // 'This chart show the average time spent into each column for the last %d tasks.' => '',
- // 'Average Lead and Cycle time' => '',
- // 'Average lead time: ' => '',
- // 'Average cycle time: ' => '',
- // 'Cycle Time' => '',
- // 'Lead Time' => '',
- // 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
- // 'Average time into each column' => '',
- // 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
- // 'Lead time: ' => '',
- // 'Cycle time: ' => '',
- // 'Time spent into each column' => '',
- // 'The lead time is the duration between the task creation and the completion.' => '',
- // 'The cycle time is the duration between the start date and the completion.' => '',
- // 'If the task is not closed the current time is used instead of the completion date.' => '',
- // 'Set automatically the start date' => '',
- // 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
- // 'Remote user' => '',
- // 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
- // 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
- // 'New remote user' => '',
- // 'New local user' => '',
- // 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
- // 'This feature does not work with all browsers.' => '',
- // 'There is no destination project available.' => '',
- // 'Trigger automatically subtask time tracking' => '',
- // 'Include closed tasks in the cumulative flow diagram' => '',
- // 'Current swimlane: %s' => '',
- // 'Current column: %s' => '',
- // 'Current category: %s' => '',
- // 'no category' => '',
- // 'Current assignee: %s' => '',
- // 'not assigned' => '',
- // 'Author:' => '',
- // 'contributors' => '',
- // 'License:' => '',
- // 'License' => '',
- // 'Project Administrator' => '',
- // 'Enter the text below' => '',
- // 'Gantt chart for %s' => '',
- // 'Sort by position' => '',
- // 'Sort by date' => '',
- // 'Add task' => '',
- // 'Start date:' => '',
- // 'Due date:' => '',
- // 'There is no start date or due date for this task.' => '',
- // 'Moving or resizing a task will change the start and due date of the task.' => '',
- // 'There is no task in your project.' => '',
- // 'Gantt chart' => '',
- // 'People who are project managers' => '',
- // 'People who are project members' => '',
- // 'NOK - Norwegian Krone' => '',
- // 'Show this column' => '',
- // 'Hide this column' => '',
- // 'open file' => '',
- // 'End date' => '',
- // 'Users overview' => '',
- // 'Managers' => '',
- // 'Members' => '',
- // 'Shared project' => '',
- // 'Project managers' => '',
- // 'Project members' => '',
- // 'Gantt chart for all projects' => '',
- // 'Projects list' => '',
- // 'Gantt chart for this project' => '',
- // 'Project board' => '',
- // 'End date:' => '',
- // 'There is no start date or end date for this project.' => '',
- // 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
- // 'Link type' => '',
- // 'Change task color when using a specific task link' => '',
- // 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
+ 'Switch to the board view' => 'Passa alla vista "bacheca"',
+ 'Switch to the calendar view' => 'Passa alla vista "calendario"',
+ 'Switch to the list view' => 'Passa alla vista "elenco"',
+ 'Go to the search/filter box' => 'Vai alla casella di ricerca/filtro',
+ 'There is no activity yet.' => 'Non è presente ancora nessuna attività.',
+ 'No tasks found.' => 'Nessun task trovato.',
+ 'Keyboard shortcut: "%s"' => 'Scorciatoia da tastiera: "%s"',
+ 'List' => 'Lista',
+ 'Filter' => 'Filtro',
+ 'Advanced search' => 'Riceca avanzata',
+ 'Example of query: ' => 'Esempio di query: ',
+ 'Search by project: ' => 'Ricerca per progetto: ',
+ 'Search by column: ' => 'Ricerca per colonna: ',
+ 'Search by assignee: ' => 'Ricerca per assegnatario: ',
+ 'Search by color: ' => 'Ricerca per colore: ',
+ 'Search by category: ' => 'Ricerca per categoria: ',
+ 'Search by description: ' => 'Ricerca per descrizione: ',
+ 'Search by due date: ' => 'Ricerca per data di scadenza: ',
+ 'Lead and Cycle time for "%s"' => 'Tempo di consegna (Lead Time) e lavorazione (Cycle Time) per "%s"',
+ 'Average time spent into each column for "%s"' => 'Tempo medio trascorso in ogni colonna per "%s"',
+ 'Average time spent into each column' => 'Tempo medio trascorso in ogni colonna',
+ 'Average time spent' => 'Tempo medio trascorso',
+ 'This chart show the average time spent into each column for the last %d tasks.' => 'Questo grafico mostra il tempo medio trascorso in ogni colonna per gli ultimi %d task.',
+ 'Average Lead and Cycle time' => 'Tempo medio di consegna (Lead Time) e lavorazione (Cycle Time)',
+ 'Average lead time: ' => 'Tempo medio di consegna (Lead Time): ',
+ 'Average cycle time: ' => 'Tempo medio di lavorazione (Cycle Time): ',
+ 'Cycle Time' => 'Tempo di lavorazione (Cycle Time)',
+ 'Lead Time' => 'Tempo di consegna (Lead Time)',
+ 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Questo grafico mostra i tempi medi di consegna (Lead Time) e lavorazione (Cycle Time) per gli ultimi %d task.',
+ 'Average time into each column' => 'Tempo medio in ogni colonna',
+ 'Lead and cycle time' => 'Tempo di consegna e lavorazione',
+ 'Lead time: ' => 'Tempo di consegna (Lead Time): ',
+ 'Cycle time: ' => 'Tempo di lavorazione (Cycle Time): ',
+ 'Time spent into each column' => 'Tempo trascorso in ogni colonna',
+ 'The lead time is the duration between the task creation and the completion.' => 'Il tempo di consegna (Lead Time) è la durata tra la creazione di un task ed il suo completamento.',
+ 'The cycle time is the duration between the start date and the completion.' => 'Il tempo di lavorazione (Cycle Time) è la durata tra la data di inzio della lavorazione di un task ed il suo completamento.',
+ 'If the task is not closed the current time is used instead of the completion date.' => 'Se il task non è chiuso sarà usata la data attuale invece della data di completamento.',
+ 'Set automatically the start date' => 'Imposta automaticamente la data di inzio',
+ 'Edit Authentication' => 'Modifica Autenticazione',
+ 'Remote user' => 'Utente remoto',
+ 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'La password degli utenti remoti (ad esempio: LDAP, account Google e Github) non è salvata nel database di Kanboard',
+ 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Se imposti l\'opzione "Disabilita il form di login", le credenzali inserite nella saranno ignorate.',
+ 'New remote user' => 'Nuovo utente remoto',
+ 'New local user' => 'Nuovo utente locale',
+ 'Default task color' => 'Colore predefinito dei task',
+ 'This feature does not work with all browsers.' => 'Questa feature non funziona con tutti i browser.',
+ 'There is no destination project available.' => 'Non ci sono progetti disponbili come destinazione.',
+ 'Trigger automatically subtask time tracking' => 'Attiva automaticamente il time-tracking per i sotto-task',
+ 'Include closed tasks in the cumulative flow diagram' => 'Includi i task chiusi nel diagramma di flusso cumulativo',
+ 'Current swimlane: %s' => 'Corsia attuale: %s',
+ 'Current column: %s' => 'Colonna attuale: %s',
+ 'Current category: %s' => 'Categoria attuale: %s',
+ 'no category' => 'nessuna categoria',
+ 'Current assignee: %s' => 'Assegnatario attuale: %s',
+ 'not assigned' => 'non assegnato',
+ 'Author:' => 'Autore',
+ 'contributors' => 'contributori',
+ 'License:' => 'Licenza:',
+ 'License' => 'Licenza',
+ 'Enter the text below' => 'Inserisci il testo qui sotto',
+ 'Gantt chart for %s' => 'Grafico Gantt per %s',
+ 'Sort by position' => 'Ordina per posizione',
+ 'Sort by date' => 'Ordina per data',
+ 'Add task' => 'Aggiungi task',
+ 'Start date:' => 'Data di inizio:',
+ 'Due date:' => 'Data di completamento:',
+ 'There is no start date or due date for this task.' => 'Nessuna data di inzio o di scadenza per questo task.',
+ 'Moving or resizing a task will change the start and due date of the task.' => 'Spostando o ridimensionado un task ne modifca la data di inzio e di scadenza.',
+ 'There is no task in your project.' => 'Non ci sono task nel tuo progetto.',
+ 'Gantt chart' => 'Grafici Gantt',
+ 'People who are project managers' => 'Persone che sono manager di progetto',
+ 'People who are project members' => 'Persone che sono membri di progetto',
+ 'NOK - Norwegian Krone' => 'NOK - Corone norvegesi',
+ 'Show this column' => 'Mostra questa colonna',
+ 'Hide this column' => 'Nascondi questa colonna',
+ 'open file' => 'apri file',
+ 'End date' => 'Data di fine',
+ 'Users overview' => 'Panoramica utenti',
+ 'Members' => 'Membri',
+ 'Shared project' => 'Progetto condiviso',
+ 'Project managers' => 'Manager del progetto',
+ 'Gantt chart for all projects' => 'Grafico Gantt per tutti i progetti',
+ 'Projects list' => 'Elenco progetti',
+ 'Gantt chart for this project' => 'Grafico Gantt per questo progetto',
+ 'Project board' => 'Bacheca del progetto',
+ 'End date:' => 'Data di fine:',
+ 'There is no start date or end date for this project.' => 'Non è prevista una data di inzio o fine per questo progetto.',
+ 'Projects Gantt chart' => 'Grafico Gantt dei progetti',
+ 'Link type' => 'Tipo di link',
+ 'Change task color when using a specific task link' => 'Cambia colore del task quando si un utilizza una determinata relazione di task',
+ 'Task link creation or modification' => 'Creazione o modifica di relazione di task',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
- // 'Documentation: %s' => '',
- // 'Switch to the Gantt chart view' => '',
- // 'Reset the search/filter box' => '',
- // 'Documentation' => '',
- // 'Table of contents' => '',
- // 'Gantt' => '',
- // 'Help with project permissions' => '',
- // 'Author' => '',
- // 'Version' => '',
- // 'Plugins' => '',
- // 'There is no plugin loaded.' => '',
- // 'Set maximum column height' => '',
- // 'Remove maximum column height' => '',
- // 'My notifications' => '',
- // 'Custom filters' => '',
- // 'Your custom filter have been created successfully.' => '',
- // 'Unable to create your custom filter.' => '',
- // 'Custom filter removed successfully.' => '',
- // 'Unable to remove this custom filter.' => '',
- // 'Edit custom filter' => '',
- // 'Your custom filter have been updated successfully.' => '',
- // 'Unable to update custom filter.' => '',
+ 'Documentation: %s' => 'Documentazione: %s',
+ 'Switch to the Gantt chart view' => 'Passa alla vista Grafico Gantt',
+ 'Reset the search/filter box' => 'Resetta la riceca/filtro',
+ 'Documentation' => 'Documentazione',
+ 'Table of contents' => 'Indice dei contenuti',
+ 'Gantt' => 'Gantt',
+ 'Author' => 'Autore',
+ 'Version' => 'Versione',
+ 'Plugins' => 'Plugin',
+ 'There is no plugin loaded.' => 'Nessun plugin è stato caricato.',
+ 'Set maximum column height' => 'Imposta l\'altezza massima della colonna',
+ 'Remove maximum column height' => 'Rimuovi l\'altezza massima della colonna',
+ 'My notifications' => 'Le mie notifiche',
+ 'Custom filters' => 'Filtri personalizzati',
+ 'Your custom filter have been created successfully.' => 'Il filtro personalizzato è stato creato con successo.',
+ 'Unable to create your custom filter.' => 'Impossibile creare il filtro personalizzato.',
+ 'Custom filter removed successfully.' => 'Filtro personalizzato rimosso con successo.',
+ 'Unable to remove this custom filter.' => 'Impossibile rimuovere questo filtro personalizzato',
+ 'Edit custom filter' => 'Modifica il filtro personalizzato',
+ 'Your custom filter have been updated successfully.' => 'Il filtro personalizzato è stato aggiornato con successo.',
+ 'Unable to update custom filter.' => 'Impossibile aggiornare il filtro personalizzato.',
// 'Web' => '',
- // 'New attachment on task #%d: %s' => '',
- // 'New comment on task #%d' => '',
- // 'Comment updated on task #%d' => '',
- // 'New subtask on task #%d' => '',
- // 'Subtask updated on task #%d' => '',
- // 'New task #%d: %s' => '',
- // 'Task updated #%d' => '',
- // 'Task #%d closed' => '',
- // 'Task #%d opened' => '',
- // 'Column changed for task #%d' => '',
- // 'New position for task #%d' => '',
- // 'Swimlane changed for task #%d' => '',
- // 'Assignee changed on task #%d' => '',
- // '%d overdue tasks' => '',
- // 'Task #%d is overdue' => '',
- // 'No new notifications.' => '',
- // 'Mark all as read' => '',
- // 'Mark as read' => '',
- // 'Total number of tasks in this column across all swimlanes' => '',
- // 'Collapse swimlane' => '',
- // 'Expand swimlane' => '',
- // 'Add a new filter' => '',
- // 'Share with all project members' => '',
- // 'Shared' => '',
- // 'Owner' => '',
- // 'Unread notifications' => '',
- // 'My filters' => '',
- // 'Notification methods:' => '',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
+ 'New attachment on task #%d: %s' => 'Nuovo allegato nel task #%d: %s',
+ 'New comment on task #%d' => 'Nuovo commento nel task #%d',
+ 'Comment updated on task #%d' => 'Commento aggiornato nel task #%d',
+ 'New subtask on task #%d' => 'Nuovo sotto-task nel task #%d',
+ 'Subtask updated on task #%d' => 'Sotto-task aggiornato nel task #%d',
+ 'New task #%d: %s' => 'Nuovo task #%d: %s',
+ 'Task updated #%d' => 'Task #%d aggiornato',
+ 'Task #%d closed' => 'Task #%d chiuso',
+ 'Task #%d opened' => 'Task #%d aperto',
+ 'Column changed for task #%d' => 'Colonna modificata per il task #%d',
+ 'New position for task #%d' => 'Nuova posizione per il task #%d',
+ 'Swimlane changed for task #%d' => 'Corsia modificata per il task #%d',
+ 'Assignee changed on task #%d' => 'Assegnatario modificato per il task #%d',
+ '%d overdue tasks' => '%d task scaduti',
+ 'Task #%d is overdue' => 'Il task #%d è scaduto',
+ 'No new notifications.' => 'Nessuna nuova notifica.',
+ 'Mark all as read' => 'Segna tutti come letti',
+ 'Mark as read' => 'Segna come letto',
+ 'Total number of tasks in this column across all swimlanes' => 'Numero totale di task in questa colonna per tutte le corsie',
+ 'Collapse swimlane' => 'Minimizza corsia',
+ 'Expand swimlane' => 'Espandi corsia',
+ 'Add a new filter' => 'Aggiungi un nuovo filtro',
+ 'Share with all project members' => 'Condividi con tutti i membri del progetto',
+ 'Shared' => 'Condiviso',
+ 'Owner' => 'Proprietario',
+ 'Unread notifications' => 'Notifiche non lette',
+ 'My filters' => 'I miei filtri',
+ 'Notification methods:' => 'Metodi di notifica',
+ 'Import tasks from CSV file' => 'Importa task da file CSV',
+ 'Unable to read your file' => 'Impossibile leggere il file',
+ '%d task(s) have been imported successfully.' => '%d task sono stati importati con sucesso.',
+ 'Nothing have been imported!' => 'Non è stato importato nulla!',
+ 'Import users from CSV file' => 'Importa utenti da file CSV',
+ '%d user(s) have been imported successfully.' => '%d utenti importati con successo.',
+ 'Comma' => 'Virgola',
+ 'Semi-colon' => 'Punto e virgola',
+ 'Tab' => 'Tabulazione',
+ 'Vertical bar' => 'Barra verticale',
+ 'Double Quote' => 'Apice singolo',
+ 'Single Quote' => 'Doppio apice',
+ '%s attached a file to the task #%d' => '%s ha allegato un file al task #%d',
+ 'There is no column or swimlane activated in your project!' => 'Non ci sono colonne o corsie attive all\'interno del tuo progetto!',
+ 'Append filter (instead of replacement)' => 'Aggiungi filtro (anzichè sostituirlo)',
+ 'Append/Replace' => 'Aggiungi/Sostituisci',
+ 'Append' => 'Aggiungi',
+ 'Replace' => 'Sostituisci',
+ 'Import' => 'Importa',
+ 'change sorting' => 'cambia ordinamento',
+ 'Tasks Importation' => 'Importazione task',
+ 'Delimiter' => 'Delimitatore',
// 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'CSV File' => 'File CSV',
+ 'Instructions' => 'Istruzioni',
+ 'Your file must use the predefined CSV format' => 'Il file deve rispettare il formato CSV predefinito',
+ 'Your file must be encoded in UTF-8' => 'Il file deve essere codificato in formato UTF-8',
+ 'The first row must be the header' => 'La prima riga deve contenere l\'intestazione',
+ 'Duplicates are not verified for you' => 'Le righe duplicate non verranno controllate',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'La data di scadenza deve usare il formato ISO: YYYY-MM-DD',
+ 'Download CSV template' => 'Scarica il template CSV',
+ 'No external integration registered.' => 'Nessuna integrazione esterna presente.',
+ 'Duplicates are not imported' => 'I duplicati non veranno importati',
+ 'Usernames must be lowercase and unique' => 'I nomi utente devono essere in minuscolo e univoci',
+ 'Passwords will be encrypted if present' => 'Se presenti, le password verranno criptate',
+ '%s attached a new file to the task %s' => '%s ha allegato un nuovo file al task %s',
+ 'Assign automatically a category based on a link' => 'Assegna automaticamente una categoria sulla base di una relazione',
+ // 'BAM - Konvertible Mark' => '',
+ 'Assignee Username' => 'Nome utente dell\'assegnatario',
+ 'Assignee Name' => 'Nome dell\'assegnatario',
+ 'Groups' => 'Gruppi',
+ 'Members of %s' => 'Membri di %s',
+ 'New group' => 'Nuovo gruppo',
+ 'Group created successfully.' => 'Gruppo creato con successo',
+ 'Unable to create your group.' => 'Impossibile creare il gruppo',
+ 'Edit group' => 'Modifica gruppo',
+ 'Group updated successfully.' => 'Gruppo aggiornato con sucesso.',
+ 'Unable to update your group.' => 'Impossibile aggiornare il gruppo',
+ 'Add group member to "%s"' => 'Aggiungi un membro al gruppo "%s"',
+ 'Group member added successfully.' => 'Membro del gruppo aggiunto con successo.',
+ 'Unable to add group member.' => 'Impossibile aggiungere un membro del gruppo',
+ 'Remove user from group "%s"' => 'Rimuovi utente dal gruppo "%s"',
+ 'User removed successfully from this group.' => 'Utente rimosso dal gruppo con successo.',
+ 'Unable to remove this user from the group.' => 'Impossibile rimuovere l\'utentei dal gruppo.',
+ 'Remove group' => 'Rimuovi gruppo',
+ 'Group removed successfully.' => 'Gruppo rimosso con sucesso.',
+ 'Unable to remove this group.' => 'Impossibile rimuovere questo gruppo.',
+ 'Project Permissions' => 'Permessi del progetto',
+ // 'Manager' => '',
+ 'Project Manager' => 'Manager del progetto',
+ 'Project Member' => 'Membro del progetto',
+ 'Project Viewer' => 'Osservatore del progetto',
+ 'Your account is locked for %d minutes' => 'Il tuo account è bloccato per %d minuti',
+ 'Invalid captcha' => 'Captcha non valido',
+ 'The name must be unique' => 'Il nome deve essere univoco',
+ 'View all groups' => 'Visualiza tutti i gruppi',
+ 'View group members' => 'Visualizza i membri del gruppo',
+ 'There is no user available.' => 'Nessun utente disponibile.',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Vuoi davvero rimuovere l\'utente "%s" dal gruppo "%s"?',
+ 'There is no group.' => 'Nessun gruppo presente',
+ 'External Id' => 'Id esterno',
+ 'Add group member' => 'Aggiungi un membro del gruppo',
+ 'Do you really want to remove this group: "%s"?' => 'Vuoi davvero rimuovere questo gruppo: "%s"?',
+ 'There is no user in this group.' => 'Nessun utente in questo gruppo.',
+ 'Remove this user' => 'Rimuovi questo utente',
+ 'Permissions' => 'Permessi',
+ 'Allowed Users' => 'Utenti autorizzati',
+ 'No user have been allowed specifically.' => 'Nessun utente è stato esplicitamente autorizzato.',
+ 'Role' => 'Ruolo',
+ 'Enter user name...' => 'Inserisci il nome utente...',
+ 'Allowed Groups' => 'Gruppi autorizzati',
+ 'No group have been allowed specifically.' => 'Nessun gruppo è stato esplicitamente autorizzato.',
+ 'Group' => 'Gruppo',
+ 'Group Name' => 'Nome del gruppo',
+ 'Enter group name...' => 'Inserisci il nome del gruppo...',
+ 'Role:' => 'Ruolo:',
+ 'Project members' => 'Membri del progetto',
+ 'Compare hours for "%s"' => 'Confronta le ore per "%s"',
+ '%s mentioned you in the task #%d' => '%s ti ha menzionato nel task #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s ti ha menzionato in un commento del task #%d',
+ 'You were mentioned in the task #%d' => 'Sei stato menzionato nel task #%d',
+ 'You were mentioned in a comment on the task #%d' => 'Sei stato menzionato in un commento del task #%d',
+ 'Mentioned' => 'Menzionato',
+ 'Compare Estimated Time vs Actual Time' => 'Confronta il Tempo Stimato vs Tempo Effettivo',
+ 'Estimated hours: ' => 'Ore stimate: ',
+ 'Actual hours: ' => 'Ore effettive: ',
+ 'Hours Spent' => 'Ore impiegate',
+ 'Hours Estimated' => 'Ore stimate',
+ 'Estimated Time' => 'Tempo stimato',
+ 'Actual Time' => 'Tempo effettivo',
+ 'Estimated vs actual time' => 'Tempo stimato vs Tempo effettivo',
+ 'RUB - Russian Ruble' => 'RUB - Rublo russo',
+ 'Assign the task to the person who does the action when the column is changed' => 'Assegna il task alla persona che esegue l\'azione quando la colonna viene modificata',
+ 'Close a task in a specific column' => 'Chiudi un task in una specifica colonna',
+ 'Time-based One-time Password Algorithm' => 'Algoritmo per la Time-based One-time Password',
+ // 'Two-Factor Provider: ' => '',
+ 'Disable two-factor authentication' => 'Disabilita l\'autenticazione "two-factor"',
+ 'Enable two-factor authentication' => 'Abilita l\'autenticazione "two-factor"',
+ 'There is no integration registered at the moment.' => 'Nessuna integrazione disponibile al momento.',
+ 'Password Reset for Kanboard' => 'Reimposta password per Kanboard',
+ 'Forgot password?' => 'Password dimenticata?',
+ 'Enable "Forget Password"' => 'Abilita funzione "Password dimenticata"',
+ 'Password Reset' => 'Reimposta password',
+ 'New password' => 'Nuova password',
+ 'Change Password' => 'Cambia password',
+ 'To reset your password click on this link:' => 'Per reimpostare la tua password clicca su questo link:',
+ 'Last Password Reset' => 'Ultimo cambio password',
+ 'The password has never been reinitialized.' => 'La password non è mai stata reimpostata.',
+ 'Creation' => 'Creazione',
+ 'Expiration' => 'Scadenza',
+ 'Password reset history' => 'Storico cambio password',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Tutti i task della colonna "%s" e della corsia "%s" sono stati chiusi con successo.',
+ 'Do you really want to close all tasks of this column?' => 'Vuoi veramente chiudere tutti i task di questa colonna?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d task della colonna "%s" e della corsia "%s" saranno chiusi.',
+ 'Close all tasks of this column' => 'Chiudi tutti i task di questa colonna',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Nessun plugin ha caricato un metodo di notifica di progetto. Puoi tuttavia configurare le notifiche personali dal tuo profilo utente.',
+ 'My dashboard' => 'La mia bacheca',
+ 'My profile' => 'Il mio profilo',
+ 'Project owner: ' => 'Proprietario del progetto: ',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'L\'identificativo del progetto è opzionale e deve essere alfanumerico, ad esempio: MIOPROGETTO.',
+ 'Project owner' => 'Proprietario del progetto',
+ 'Those dates are useful for the project Gantt chart.' => 'Le seguenti date sono utilizzate per i grafici Gantt di progetto.',
+ 'Private projects do not have users and groups management.' => 'Per i progetti privati non è prevista la gestione di utenti e gruppi.',
+ 'There is no project member.' => 'Non è impostato un membro del progetto.',
+ 'Priority' => 'Priorità',
+ 'Task priority' => 'Priorità del task',
+ 'General' => 'Generale',
+ 'Dates' => 'Date',
+ 'Default priority' => 'Priorità predefinita',
+ 'Lowest priority' => 'Priorità minima',
+ 'Highest priority' => 'Priorità massima',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'Se imposti a zero la priorità massima e minima, questa funzionalità sarà disabilitata.',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php
index fb40271d..3d22ce32 100644
--- a/app/Locale/ja_JP/translations.php
+++ b/app/Locale/ja_JP/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'プロジェクトの削除',
'Edit the board for "%s"' => 'ボード「%s」を変更する',
'All projects' => 'すべてのプロジェクト',
- 'Change columns' => 'カラムの変更',
'Add a new column' => 'カラムの追加',
'Title' => 'タイトル',
'Nobody assigned' => '担当なし',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'タスクをオープンする',
'Do you really want to open this task: "%s"?' => 'タスク「%s」をオープンしますか?',
'Back to the board' => 'ボードに戻る',
- 'Created on %B %e, %Y at %k:%M %p' => '%Y/%m/%d %H:%M に作成',
'There is nobody assigned' => '担当者がいません',
'Column on the board:' => 'カラム: ',
- 'Status is open' => 'ステータスはオープンです',
- 'Status is closed' => 'ステータスはクローズです',
'Close this task' => 'タスクをクローズする',
'Open this task' => 'タスクをオープンする',
'There is no description.' => '説明がありません',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'ID が必要です',
'The project id is required' => 'プロジェクト ID が必要です',
'The project name is required' => 'プロジェクト名が必要です',
- 'This project must be unique' => 'プロジェクト名がすでに使われています',
'The title is required' => 'タイトルが必要です',
'Settings saved successfully.' => '設定を保存しました。',
'Unable to save your settings.' => '設定の保存に失敗しました。',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Work in progress',
'Done' => 'Done',
'Application version:' => 'アプリケーションのバージョン:',
- 'Completed on %B %e, %Y at %k:%M %p' => '%Y/%m/%d %H:%M に完了',
- '%B %e, %Y at %k:%M %p' => '%Y/%m/%d %H:%M',
- 'Date created' => '作成日',
- 'Date completed' => '完了日',
'Id' => 'ID',
'%d closed tasks' => '%d 個のクローズしたタスク',
'No task for this project' => 'このプロジェクトにタスクがありません',
@@ -175,13 +166,7 @@ return array(
'Complexity' => '複雑さ',
'Task limit' => 'タスク数制限',
'Task count' => 'タスク数',
- 'Edit project access list' => 'プロジェクトのアクセス許可を変更',
- 'Allow this user' => 'このユーザを許可する',
- 'Don\'t forget that administrators have access to everything.' => '管理者には全ての権限が与えられます。',
- 'Revoke' => '許可を取り下げる',
- 'List of authorized users' => '許可されたユーザ',
'User' => 'ユーザ',
- 'Nobody have access to this project.' => 'だれもプロジェクトにアクセスできません。',
'Comments' => 'コメント',
'Write your text in Markdown' => 'Markdown 記法で書く',
'Leave a comment' => 'コメントを書く',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'タスクを変更する',
'Due Date' => '期限',
'Invalid date' => '日付が無効です',
- 'Must be done before %B %e, %Y' => '%Y/%m/%d までに完了',
- '%B %e, %Y' => '%Y %B %e',
- '%b %e, %Y' => '%Y %b %e',
'Automatic actions' => '自動アクションを管理する',
'Your automatic action have been created successfully.' => '自動アクションを作成しました。',
'Unable to create your automatic action.' => '自動アクションの作成に失敗しました。',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => '色をユーザに割り当てる',
'Column title' => 'カラムのタイトル',
'Position' => '位置',
- 'Move Up' => '上に動かす',
- 'Move Down' => '下に動かす',
'Duplicate to another project' => '別のプロジェクトに複製する',
'Duplicate' => '複製する',
'link' => 'リンク',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'コメントを削除しました。',
'Unable to remove this comment.' => 'コメントの削除に失敗しました。',
'Do you really want to remove this comment?' => 'コメントを削除しますか?',
- 'Only administrators or the creator of the comment can access to this page.' => '管理者かコメントの作成者のみがこのページアクセスできます。',
'Current password for the user "%s"' => 'ユーザ「%s」の現在のパスワード',
'The current password is required' => '現在のパスワードを入力してください',
'Wrong password' => 'パスワードが違います',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'Email',
- 'Link my Google Account' => 'Google アカウントをリンクする',
- 'Unlink my Google Account' => 'Google アカウントのリンクを解除する',
- 'Login with my Google Account' => 'Google アカウントでログインする',
- 'Project not found.' => 'プロジェクトが見つかりません。',
'Task removed successfully.' => 'タスクを削除しました。',
'Unable to remove this task.' => 'タスクの削除に失敗しました。',
'Remove a task' => 'タスクの削除',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => '最大: ',
'Unable to upload the file.' => 'ファイルのアップロードに失敗しました。',
'Display another project' => '別のプロジェクトを表示',
- 'Login with my Github Account' => 'Github アカウントでログインする',
- 'Link my Github Account' => 'Github アカウントをリンクする',
- 'Unlink my Github Account' => 'Github アカウントとのリンクを解除する',
'Created by %s' => '%s が作成',
- 'Last modified on %B %e, %Y at %k:%M %p' => ' %Y/%m/%d %H:%M に変更',
'Tasks Export' => 'タスクの出力',
'Tasks exportation for "%s"' => '「%s」のタスク出力',
'Start Date' => '開始日',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => '以下のプロジェクトにのみ通知を受け取る:',
'view the task on Kanboard' => 'Kanboard でタスクを見る',
'Public access' => '公開アクセス設定',
- 'User management' => 'ユーザを管理する',
'Active tasks' => 'アクティブなタスク',
'Disable public access' => '公開アクセスを無効にする',
'Enable public access' => '公開アクセスを有効にする',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Email:',
'Notifications:' => '通知:',
'Notifications' => '通知',
- 'Group:' => 'グループ:',
- 'Regular user' => '通常のユーザ',
'Account type:' => 'アカウントの種類:',
'Edit profile' => 'プロフィールの変更',
'Change password' => 'パスワードの変更',
'Password modification' => 'パスワードの変更',
'External authentications' => '外部認証',
- 'Google Account' => 'Google アカウント',
- 'Github Account' => 'Github アカウント',
'Never connected.' => '未接続。',
- 'No account linked.' => 'アカウントがリンクしていません。',
- 'Account linked.' => 'アカウントがリンクしました。',
'No external authentication enabled.' => '外部認証が設定されていません。',
'Password modified successfully.' => 'パスワードを変更しました。',
'Unable to change the password.' => 'パスワードが変更できませんでした。',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s がタスク %s の担当を %s に変更しました',
'New password for the user "%s"' => 'ユーザ「%s」の新しいパスワード',
'Choose an event' => 'イベントの選択',
- 'Github commit received' => 'Github のコミットを受け取った',
- 'Github issue opened' => 'Github Issue がオープンされた',
- 'Github issue closed' => 'Github Issue がクローズされた',
- 'Github issue reopened' => 'Github Issue が再オープンされた',
- 'Github issue assignee change' => 'Github Issue の担当が変更された',
- 'Github issue label change' => 'Github のラベルが変更された',
'Create a task from an external provider' => 'タスクを外部サービスから作成する',
'Change the assignee based on an external username' => '担当者を外部サービスに基いて変更する',
'Change the category based on an external label' => 'カテゴリを外部サービスに基いて変更する',
'Reference' => '参照',
- 'Reference: %s' => '参照: %s',
'Label' => 'ラベル',
'Database' => 'データベース',
'About' => '情報',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => '秒数 (デフォルト 60 秒)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => '秒数 (0 は機能を無効化、デフォルト 10 秒)',
'Application URL' => 'アプリケーションの URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Exemple : http://exemple.kanboard.net/ (Email 通知に利用)',
'Token regenerated.' => 'トークンが再生成されました。',
'Date format' => 'データのフォーマット',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO フォーマットが入力できます(例: %s または %s)',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'このプロジェクトは非公開です',
'Type here to create a new sub-task' => 'サブタスクを追加するにはここに入力してください',
'Add' => '追加',
- 'Estimated time: %s hours' => '予想時間: %s 時間',
- 'Time spent: %s hours' => '経過: %s 時間',
- 'Started on %B %e, %Y' => '開始 %Y/%m/%d',
'Start date' => '開始時間',
'Time estimated' => '予想時間',
'There is nothing assigned to you.' => '何もアサインされていません。',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => '誰でもこのプロジェクトにアクセスできます。',
'Webhooks' => 'Webhook',
'API' => 'API',
- 'Github webhooks' => 'Github Webhook',
- 'Help on Github webhooks' => 'Github webhook のヘルプ',
'Create a comment from an external provider' => '外部サービスからコメントを作成する',
- 'Github issue comment created' => 'Github Issue コメントが作られました',
'Project management' => 'プロジェクト・マネジメント',
'My projects' => '自分のプロジェクト',
'Columns' => 'カラム',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => '「%s」の担当者分布',
'Clone this project' => 'このプロジェクトを複製する',
'Column removed successfully.' => 'カラムを削除しました',
- 'Github Issue' => 'Github Issue',
'Not enough data to show the graph.' => 'グラフを描画するには出たが足りません',
'Previous' => '戻る',
'The id must be an integer' => 'id は数字でなければなりません',
@@ -547,10 +496,7 @@ return array(
'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.' => 'スイムレーンが見つかりません。',
@@ -558,24 +504,13 @@ return array(
'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)' => '新しいプロジェクトのデフォルトカテゴリー (コンマ区切り)',
- 'Gitlab commit received' => 'Gitlab コミットを受診しました',
- 'Gitlab issue opened' => 'Gitlab Issue がオープンされました',
- 'Gitlab issue closed' => 'Gitlab Issue がクローズされました',
- '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',
'Subtask Id' => 'サブタスク Id',
'Subtasks' => 'サブタスク',
'Subtasks Export' => 'サブタスクの出力',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'すでに進行中のサブタスクがあります。',
'Which parts of the project do you want to duplicate?' => 'プロジェクトの何を複製しますか?',
// 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Bitbucket コミットを受信しました',
- 'Bitbucket webhooks' => 'Bitbucket Webhooks',
- 'Help on Bitbucket webhooks' => 'Bitbucket Webhooks のヘルプ',
'Start' => '開始',
'End' => '終了',
'Task age in days' => 'タスクの経過日数',
@@ -646,7 +578,6 @@ return array(
'This task' => 'このタスクは',
'<1h' => '<1時間',
'%dh' => '%d 時間',
- '%b %e' => '%b/%e',
'Expand tasks' => 'タスクを展開する',
'Collapse tasks' => 'タスクを閉じる',
'Expand/collapse tasks' => 'タスクの展開/閉じる',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'キーボードショートカット',
'Open board switcher' => 'ボード切り替えを開く',
'Application' => 'アプリケーション',
- 'since %B %e, %Y at %k:%M %p' => '%Y/%m/%d %k:%M から',
'Compact view' => 'コンパクトビュー',
'Horizontal scrolling' => '縦スクロール',
'Compact/wide view' => 'コンパクト/ワイドビュー',
'No results match:' => '結果が一致しませんでした',
'Currency' => '通貨',
- 'Files' => 'ファイル',
- 'Images' => '画像',
'Private project' => 'プライベートプロジェクト',
'AUD - Australian Dollar' => 'AUD - 豪ドル',
'CAD - Canadian Dollar' => 'CAD - 加ドル',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => '2 段認証コードは有効です。',
'Code' => 'コード',
'Two factor authentication' => '2 段認証',
- 'Enable/disable two factor authentication' => '2 段認証の有効/無効',
'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 Authenticator や FreeOTP など)',
'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' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
// 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
// 'Send a task by email to someone' => '',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
// 'Column change' => '',
// 'Position change' => '',
// 'Swimlane change' => '',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
// 'I want to receive notifications for:' => '',
// 'All tasks' => '',
// 'Only for tasks assigned to me' => '',
// 'Only for tasks created by me' => '',
// 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
// 'Not assigned' => '',
// 'View advanced search syntax' => '',
// 'Overview' => '',
- // '%b %e %Y' => '',
// 'Board/Calendar/List view' => '',
// 'Switch to the board view' => '',
// 'Switch to the calendar view' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
// 'New remote user' => '',
// 'New local user' => '',
// 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
// 'contributors' => '',
// 'License:' => '',
// 'License' => '',
- // 'Project Administrator' => '',
// 'Enter the text below' => '',
// 'Gantt chart for %s' => '',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/my_MY/translations.php b/app/Locale/my_MY/translations.php
new file mode 100644
index 00000000..e2bc6117
--- /dev/null
+++ b/app/Locale/my_MY/translations.php
@@ -0,0 +1,1151 @@
+<?php
+
+return array(
+ 'number.decimals_separator' => '.',
+ 'number.thousands_separator' => ',',
+ 'None' => 'Tiada',
+ 'edit' => 'sunting',
+ 'Edit' => 'Sunting',
+ 'remove' => 'hapus',
+ 'Remove' => 'Hapus',
+ 'Update' => 'Kemaskini',
+ 'Yes' => 'Ya',
+ 'No' => 'Tidak',
+ 'cancel' => 'batal',
+ 'or' => 'atau',
+ 'Yellow' => 'Kuning',
+ 'Blue' => 'Biru',
+ 'Green' => 'Hijau',
+ 'Purple' => 'Ungu',
+ 'Red' => 'Merah',
+ 'Orange' => 'Oren',
+ 'Grey' => 'Kelabu',
+ 'Brown' => 'Coklat',
+ 'Deep Orange' => 'Oren Gelap',
+ 'Dark Grey' => 'Kelabu Malap',
+ 'Pink' => 'Merah Jambu',
+ 'Teal' => 'Teal',
+ 'Cyan' => 'Sian',
+ 'Lime' => 'Lime',
+ 'Light Green' => 'Hijau Muda',
+ 'Amber' => 'Amber',
+ 'Save' => 'Simpan',
+ 'Login' => 'Masuk',
+ 'Official website:' => 'Laman rasmi :',
+ 'Unassigned' => 'Belum ditugaskan',
+ 'View this task' => 'Lihat tugas ini',
+ 'Remove user' => 'Hapus pengguna',
+ 'Do you really want to remove this user: "%s"?' => 'Anda yakin mahu menghapus pengguna ini : « %s » ?',
+ 'New user' => 'Pengguna baru',
+ 'All users' => 'Semua pengguna',
+ 'Username' => 'Nama pengguna',
+ 'Password' => 'Kata laluan',
+ 'Administrator' => 'Pentadbir',
+ 'Sign in' => 'Masuk',
+ 'Users' => 'Para Pengguna',
+ 'No user' => 'Tiada pengguna',
+ 'Forbidden' => 'Larangan',
+ 'Access Forbidden' => 'Akses Dilarang',
+ 'Edit user' => 'Ubah Pengguna',
+ 'Logout' => 'Keluar',
+ 'Bad username or password' => 'Nama pengguna atau kata laluan tidak sepadan',
+ 'Edit project' => 'Ubah projek',
+ 'Name' => 'Nama',
+ 'Projects' => 'Projek',
+ 'No project' => 'Tiada projek',
+ 'Project' => 'Projek',
+ 'Status' => 'Status',
+ 'Tasks' => 'Tugasan',
+ 'Board' => 'Papan',
+ 'Actions' => 'Tindakan',
+ 'Inactive' => 'Tidak Aktif',
+ 'Active' => 'Aktif',
+ 'Add this column' => 'Tambahkan kolom ini',
+ '%d tasks on the board' => '%d tugasan di papan',
+ '%d tasks in total' => 'Sejumlah %d tugasan',
+ 'Unable to update this board.' => 'Tidak berupaya mengemaskini papan ini',
+ 'Edit board' => 'ubah papan',
+ 'Disable' => 'Nyah-Upaya',
+ 'Enable' => 'Aktifkan',
+ 'New project' => 'Projek Baru',
+ 'Do you really want to remove this project: "%s"?' => 'Anda yakin mahu menghapus projek ini : « %s » ?',
+ 'Remove project' => 'Hapus projek',
+ 'Edit the board for "%s"' => 'Ubah papan untuk « %s »',
+ 'All projects' => 'Semua projek',
+ 'Add a new column' => 'Tambah kolom baru',
+ 'Title' => 'Judul',
+ 'Nobody assigned' => 'Tidak ada yang ditugaskan',
+ 'Assigned to %s' => 'Ditugaskan ke %s',
+ 'Remove a column' => 'Hapus kolom',
+ 'Remove a column from a board' => 'Hapus kolom dari papan',
+ 'Unable to remove this column.' => 'Tidak dapat menghapus kolom ini.',
+ 'Do you really want to remove this column: "%s"?' => 'Apakah anda yakin akan menghapus kolom ini : « %s » ?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'tindakan ini akan MENGHAPUS SEMUA TUGAS yang terkait dengan kolom ini!',
+ 'Settings' => 'Penetapan',
+ 'Application settings' => 'Penetapan aplikasi',
+ 'Language' => 'Bahasa',
+ 'Webhook token:' => 'Token webhook :',
+ 'API token:' => 'Token API :',
+ 'Database size:' => 'Saiz pengkalan data:',
+ 'Download the database' => 'Muat turun pengkalan data',
+ 'Optimize the database' => 'Optimakan pengkalan data',
+ '(VACUUM command)' => '(perintah VACUUM)',
+ '(Gzip compressed Sqlite file)' => '(File Sqlite yang termampat Gzip)',
+ 'Close a task' => 'Tutup tugas',
+ 'Edit a task' => 'Sunting tugas',
+ 'Column' => 'Kolom',
+ 'Color' => 'Warna',
+ 'Assignee' => 'Orang yang ditugaskan',
+ 'Create another task' => 'Buat tugas lain',
+ 'New task' => 'Tugasan baru',
+ 'Open a task' => 'Buka tugas',
+ 'Do you really want to open this task: "%s"?' => 'Anda yakin untuk buka tugas ini : « %s » ?',
+ 'Back to the board' => 'Kembali ke papan',
+ 'There is nobody assigned' => 'Tidak ada orang yand ditugaskan',
+ 'Column on the board:' => 'Kolom di dalam papan : ',
+ 'Close this task' => 'Tutup tugas ini',
+ 'Open this task' => 'Buka tugas ini',
+ 'There is no description.' => 'Tidak ada keterangan.',
+ 'Add a new task' => 'Tambah tugas baru',
+ 'The username is required' => 'Nama pengguna adalah wajib',
+ 'The maximum length is %d characters' => 'Panjang maksimum adalah %d karakter',
+ 'The minimum length is %d characters' => 'Panjang minimum adalah %d karakter',
+ 'The password is required' => 'Kata laluan adalah wajib',
+ 'This value must be an integer' => 'Nilai ini harus integer',
+ 'The username must be unique' => 'Nama pengguna semestinya unik',
+ 'The user id is required' => 'Id Pengguna adalah wajib',
+ 'Passwords don\'t match' => 'Kata laluan tidak sepadan',
+ 'The confirmation is required' => 'Pengesahan diperlukan',
+ 'The project is required' => 'Projek diperlukan',
+ 'The id is required' => 'Id diperlukan',
+ 'The project id is required' => 'Id projek diperlukan',
+ 'The project name is required' => 'Nama projek diperlukan',
+ 'The title is required' => 'Judul diperlukan',
+ 'Settings saved successfully.' => 'Penetapan berjaya disimpan.',
+ 'Unable to save your settings.' => 'Tidak dapat menyimpan penetapan anda.',
+ 'Database optimization done.' => 'Optimasi pengkalan data selesai.',
+ 'Your project have been created successfully.' => 'Projek anda berhasil dibuat.',
+ 'Unable to create your project.' => 'Tidak dapat membuat projek anda.',
+ 'Project updated successfully.' => 'projek berhasil diperbaharui.',
+ 'Unable to update this project.' => 'Tidak dapat memperbaharui projek ini.',
+ 'Unable to remove this project.' => 'Tidak dapat menghapus projek ini.',
+ 'Project removed successfully.' => 'projek berhasil dihapus.',
+ 'Project activated successfully.' => 'projek berhasil diaktivasi.',
+ 'Unable to activate this project.' => 'Tidak dapat mengaktifkan projek ini.',
+ 'Project disabled successfully.' => 'projek berhasil dinonaktifkan.',
+ 'Unable to disable this project.' => 'Tidak dapat menonaktifkan projek ini.',
+ 'Unable to open this task.' => 'Tidak dapat membuka tugas ini.',
+ 'Task opened successfully.' => 'Tugas berhasil dibuka.',
+ 'Unable to close this task.' => 'Tidak dapat menutup tugas ini.',
+ 'Task closed successfully.' => 'Tugas berhasil ditutup.',
+ 'Unable to update your task.' => 'Tidak dapat memperbaharui tugas ini.',
+ 'Task updated successfully.' => 'Tugas berhasil diperbaharui.',
+ 'Unable to create your task.' => 'Tidak dapat membuat tugas anda.',
+ 'Task created successfully.' => 'Tugas berhasil dibuat.',
+ 'User created successfully.' => 'Pengguna berhasil dibuat.',
+ 'Unable to create your user.' => 'Tidak dapat membuat pengguna anda.',
+ 'User updated successfully.' => 'Pengguna berhasil diperbaharui.',
+ 'Unable to update your user.' => 'Tidak dapat memperbaharui pengguna anda.',
+ 'User removed successfully.' => 'pengguna berhasil dihapus.',
+ 'Unable to remove this user.' => 'Tidak dapat menghapus pengguna ini.',
+ 'Board updated successfully.' => 'Papan berhasil diperbaharui.',
+ 'Ready' => 'Siap',
+ 'Backlog' => 'Tertunda',
+ 'Work in progress' => 'Sedang dalam pengerjaan',
+ 'Done' => 'Selesai',
+ 'Application version:' => 'Versi aplikasi :',
+ 'Id' => 'Id.',
+ '%d closed tasks' => '%d tugas yang ditutup',
+ 'No task for this project' => 'Tidak ada tugas dalam projek ini',
+ 'Public link' => 'Pautan publik',
+ 'Change assignee' => 'Mengubah orang yand ditugaskan',
+ 'Change assignee for the task "%s"' => 'Mengubah orang yang ditugaskan untuk tugas « %s »',
+ 'Timezone' => 'Zona waktu',
+ 'Sorry, I didn\'t find this information in my database!' => 'Maaf, saya tidak menemukan informasi ini dalam basis data saya !',
+ 'Page not found' => 'Halaman tidak ditemukan',
+ 'Complexity' => 'Kompleksitas',
+ 'Task limit' => 'Batas tugas.',
+ 'Task count' => 'Jumlah tugas',
+ 'User' => 'Pengguna',
+ 'Comments' => 'Komentar',
+ 'Write your text in Markdown' => 'Menulis teks anda didalam Markdown',
+ 'Leave a comment' => 'Tinggalkan komentar',
+ 'Comment is required' => 'Komentar diperlukan',
+ 'Leave a description' => 'Tinggalkan deskripsi',
+ 'Comment added successfully.' => 'Komentar berhasil ditambahkan.',
+ 'Unable to create your comment.' => 'Tidak dapat menambahkan komentar anda.',
+ 'Edit this task' => 'Modifikasi tugas ini',
+ 'Due Date' => 'Batas Tanggal Terakhir',
+ 'Invalid date' => 'Tanggal tidak valid',
+ 'Automatic actions' => 'Tindakan otomatis',
+ 'Your automatic action have been created successfully.' => 'Tindakan otomatis anda berhasil dibuat.',
+ 'Unable to create your automatic action.' => 'Tidak dapat membuat tindakan otomatis anda.',
+ 'Remove an action' => 'Hapus tindakan',
+ 'Unable to remove this action.' => 'Tidak dapat menghapus tindakan ini',
+ 'Action removed successfully.' => 'Tindakan berhasil dihapus.',
+ 'Automatic actions for the project "%s"' => 'Tindakan otomatis untuk projek ini « %s »',
+ 'Defined actions' => 'Tindakan didefinisikan',
+ 'Add an action' => 'Tambah tindakan',
+ 'Event name' => 'Nama acara',
+ 'Action name' => 'Nama tindakan',
+ 'Action parameters' => 'Parameter tindakan',
+ 'Action' => 'Tindakan',
+ 'Event' => 'Acara',
+ 'When the selected event occurs execute the corresponding action.' => 'Ketika acara yang dipilih terjadi, melakukan tindakan yang sesuai.',
+ 'Next step' => 'Langkah selanjutnya',
+ 'Define action parameters' => 'Definisi parameter tindakan',
+ 'Save this action' => 'Simpan tindakan ini',
+ 'Do you really want to remove this action: "%s"?' => 'Apakah anda yakin akan menghapus tindakan ini « %s » ?',
+ 'Remove an automatic action' => 'Hapus tindakan otomatis',
+ 'Assign the task to a specific user' => 'Menetapkan tugas untuk pengguna tertentu',
+ 'Assign the task to the person who does the action' => 'Memberikan tugas untuk orang yang melakukan tindakan',
+ 'Duplicate the task to another project' => 'Duplikasi tugas ke projek lain',
+ 'Move a task to another column' => 'Pindahkan tugas ke kolom lain',
+ 'Task modification' => 'Modifikasi tugas',
+ 'Task creation' => 'Membuat tugas',
+ 'Closing a task' => 'Menutup tugas',
+ 'Assign a color to a specific user' => 'Menetapkan warna untuk pengguna tertentu',
+ 'Column title' => 'Judul kolom',
+ 'Position' => 'Posisi',
+ 'Duplicate to another project' => 'Duplikasi ke projek lain',
+ 'Duplicate' => 'Duplikasi',
+ 'link' => 'Pautan',
+ 'Comment updated successfully.' => 'Komentar berhasil diperbaharui.',
+ 'Unable to update your comment.' => 'Tidak dapat memperbaharui komentar anda.',
+ 'Remove a comment' => 'Hapus komentar',
+ 'Comment removed successfully.' => 'Komentar berhasil dihapus.',
+ 'Unable to remove this comment.' => 'Tidak dapat menghapus komentar ini.',
+ 'Do you really want to remove this comment?' => 'Apakah anda yakin akan menghapus komentar ini ?',
+ 'Current password for the user "%s"' => 'Kata laluan saat ini untuk pengguna « %s »',
+ 'The current password is required' => 'Kata laluan saat ini diperlukan',
+ 'Wrong password' => 'Kata laluan salah',
+ 'Unknown' => 'Tidak diketahui',
+ 'Last logins' => 'Masuk terakhir',
+ 'Login date' => 'Tanggal masuk',
+ 'Authentication method' => 'Metode otentifikasi',
+ 'IP address' => 'Alamat IP',
+ 'User agent' => 'Agen Pengguna',
+ 'Persistent connections' => 'Koneksi persisten',
+ 'No session.' => 'Tidak ada sesi.',
+ 'Expiration date' => 'Tanggal kadaluarsa',
+ 'Remember Me' => 'Ingat Saya',
+ 'Creation date' => 'Tanggal dibuat',
+ 'Everybody' => 'Semua orang',
+ 'Open' => 'Terbuka',
+ 'Closed' => 'Ditutup',
+ 'Search' => 'Cari',
+ 'Nothing found.' => 'Tidak ditemukan.',
+ 'Due date' => 'Batas tanggal terakhir',
+ 'Others formats accepted: %s and %s' => 'Format lain yang didukung : %s et %s',
+ 'Description' => 'Deskripsi',
+ '%d comments' => '%d komentar',
+ '%d comment' => '%d komentar',
+ 'Email address invalid' => 'Alamat email tidak valid',
+ 'Your external account is not linked anymore to your profile.' => 'Akaun eksternal anda tidak lagi terhubung ke profil anda.',
+ 'Unable to unlink your external account.' => 'Tidak dapat memutuskan Akaun eksternal anda.',
+ 'External authentication failed' => 'Otentifikasi eksternal gagal',
+ 'Your external account is linked to your profile successfully.' => 'Akaun eksternal anda berhasil dihubungkan ke profil anda.',
+ 'Email' => 'Email',
+ 'Task removed successfully.' => 'Tugas berhasil dihapus.',
+ 'Unable to remove this task.' => 'Tidak dapat menghapus tugas ini.',
+ 'Remove a task' => 'Hapus tugas',
+ 'Do you really want to remove this task: "%s"?' => 'Apakah anda yakin akan menghapus tugas ini « %s » ?',
+ 'Assign automatically a color based on a category' => 'Otomatis menetapkan warna berdasarkan kategori',
+ 'Assign automatically a category based on a color' => 'Otomatis menetapkan kategori berdasarkan warna',
+ 'Task creation or modification' => 'Tugas dibuat atau di mofifikasi',
+ 'Category' => 'Kategori',
+ 'Category:' => 'Kategori :',
+ 'Categories' => 'Kategori',
+ 'Category not found.' => 'Kategori tidak ditemukan',
+ 'Your category have been created successfully.' => 'Kategori anda berhasil dibuat.',
+ 'Unable to create your category.' => 'Tidak dapat membuat kategori anda.',
+ 'Your category have been updated successfully.' => 'Kategori anda berhasil diperbaharui.',
+ 'Unable to update your category.' => 'Tidak dapat memperbaharui kategori anda.',
+ 'Remove a category' => 'Hapus kategori',
+ 'Category removed successfully.' => 'Kategori berhasil dihapus.',
+ 'Unable to remove this category.' => 'Tidak dapat menghapus kategori ini.',
+ 'Category modification for the project "%s"' => 'Modifikasi kategori untuk projek « %s »',
+ 'Category Name' => 'Nama Kategori',
+ 'Add a new category' => 'Tambah kategori baru',
+ 'Do you really want to remove this category: "%s"?' => 'Apakah anda yakin akan menghapus kategori ini « %s » ?',
+ 'All categories' => 'Semua kategori',
+ 'No category' => 'Tidak ada kategori',
+ 'The name is required' => 'Nama diperlukan',
+ 'Remove a file' => 'Hapus berkas',
+ 'Unable to remove this file.' => 'Tidak dapat menghapus berkas ini.',
+ 'File removed successfully.' => 'Berkas berhasil dihapus.',
+ 'Attach a document' => 'Lampirkan dokumen',
+ 'Do you really want to remove this file: "%s"?' => 'Apakah anda yakin akan menghapus berkas ini « %s » ?',
+ 'Attachments' => 'Lampiran',
+ 'Edit the task' => 'Modifikasi tugas',
+ 'Edit the description' => 'Modifikasi deskripsi',
+ 'Add a comment' => 'Tambahkan komentar',
+ 'Edit a comment' => 'Modifikasi komentar',
+ 'Summary' => 'Ringkasan',
+ 'Time tracking' => 'Pelacakan waktu',
+ 'Estimate:' => 'Estimasi :',
+ 'Spent:' => 'Menghabiskan:',
+ 'Do you really want to remove this sub-task?' => 'Apakah anda yakin akan menghapus sub-tugas ini ?',
+ 'Remaining:' => 'Tersisa:',
+ 'hours' => 'jam',
+ 'spent' => 'menghabiskan',
+ 'estimated' => 'perkiraan',
+ 'Sub-Tasks' => 'Sub-tugas',
+ 'Add a sub-task' => 'Tambahkan sub-tugas',
+ 'Original estimate' => 'Perkiraan semula',
+ 'Create another sub-task' => 'Tambahkan sub-tugas lainnya',
+ 'Time spent' => 'Waktu yang dihabiskan',
+ 'Edit a sub-task' => 'Modifikasi sub-tugas',
+ 'Remove a sub-task' => 'Hapus sub-tugas',
+ 'The time must be a numeric value' => 'Waktu harus berisikan numerik',
+ 'Todo' => 'Yang harus dilakukan',
+ 'In progress' => 'Sedang proses',
+ 'Sub-task removed successfully.' => 'Sub-tugas berhasil dihapus.',
+ 'Unable to remove this sub-task.' => 'Tidak dapat menghapus sub-tugas.',
+ 'Sub-task updated successfully.' => 'Sub-tugas berhasil diperbaharui.',
+ 'Unable to update your sub-task.' => 'Tidak dapat memperbaharui sub-tugas anda.',
+ 'Unable to create your sub-task.' => 'Tidak dapat membuat sub-tugas anda.',
+ 'Sub-task added successfully.' => 'Sub-tugas berhasil dibuat.',
+ 'Maximum size: ' => 'Ukuran maksimum: ',
+ 'Unable to upload the file.' => 'Tidak dapat mengunggah berkas.',
+ 'Display another project' => 'Lihat projek lain',
+ 'Created by %s' => 'Dibuat oleh %s',
+ 'Tasks Export' => 'Ekspor Tugas',
+ 'Tasks exportation for "%s"' => 'Tugas di ekspor untuk « %s »',
+ 'Start Date' => 'Tanggal Mulai',
+ 'End Date' => 'Tanggal Berakhir',
+ 'Execute' => 'Eksekusi',
+ 'Task Id' => 'Id Tugas',
+ 'Creator' => 'Pembuat',
+ 'Modification date' => 'Tanggal modifikasi',
+ 'Completion date' => 'Tanggal penyelesaian',
+ 'Clone' => 'Klon',
+ 'Project cloned successfully.' => 'Kloning projek berhasil.',
+ 'Unable to clone this project.' => 'Tidak dapat mengkloning projek.',
+ 'Enable email notifications' => 'Aktifkan pemberitahuan dari email',
+ 'Task position:' => 'Posisi tugas :',
+ 'The task #%d have been opened.' => 'Tugas #%d telah dibuka.',
+ 'The task #%d have been closed.' => 'Tugas #%d telah ditutup.',
+ 'Sub-task updated' => 'Sub-tugas diperbaharui',
+ 'Title:' => 'Judul :',
+ 'Status:' => 'Status :',
+ 'Assignee:' => 'Ditugaskan ke :',
+ 'Time tracking:' => 'Pelacakan waktu :',
+ 'New sub-task' => 'Sub-tugas baru',
+ 'New attachment added "%s"' => 'Lampiran baru ditambahkan « %s »',
+ 'Comment updated' => 'Komentar diperbaharui',
+ 'New comment posted by %s' => 'Komentar baru ditambahkan oleh « %s »',
+ 'New attachment' => 'Lampirkan baru',
+ 'New comment' => 'Komentar baru',
+ 'New subtask' => 'Sub-tugas baru',
+ 'Subtask updated' => 'Sub-tugas diperbaharui',
+ 'Task updated' => 'Tugas diperbaharui',
+ 'Task closed' => 'Tugas ditutup',
+ 'Task opened' => 'Tugas dibuka',
+ 'I want to receive notifications only for those projects:' => 'Saya ingin menerima pemberitahuan hanya untuk projek-projek yang dipilih :',
+ 'view the task on Kanboard' => 'lihat tugas di Kanboard',
+ 'Public access' => 'Akses awam',
+ 'Active tasks' => 'Tugas aktif',
+ 'Disable public access' => 'Nyahaktifkan akses awam',
+ 'Enable public access' => 'Aktifkan akses awam',
+ 'Public access disabled' => 'Akses awam dinyahaktif',
+ 'Do you really want to disable this project: "%s"?' => 'Anda yakin menyah-aktifkan projek ini : « %s » ?',
+ 'Do you really want to enable this project: "%s"?' => 'Anda yakin untuk mengaktifkan projek ini : « %s » ?',
+ 'Project activation' => 'Aktifkan projek',
+ 'Move the task to another project' => 'Pindahkan tugas ke projek lain',
+ 'Move to another project' => 'Pindahkan ke projek lain',
+ 'Do you really want to duplicate this task?' => 'Anda yakin mengembarkan tugas ini ?',
+ 'Duplicate a task' => 'Kembarkan tugas',
+ 'External accounts' => 'Akaun luaran',
+ 'Account type' => 'Jenis Akaun',
+ 'Local' => 'Lokal',
+ 'Remote' => 'Jauh',
+ 'Enabled' => 'Aktif',
+ 'Disabled' => 'Tidak aktif',
+ 'Username:' => 'Nama pengguna :',
+ 'Name:' => 'Nama:',
+ 'Email:' => 'Emel:',
+ 'Notifications:' => 'Makluman:',
+ 'Notifications' => 'Makluman',
+ 'Account type:' => 'Jenis Akaun :',
+ 'Edit profile' => 'Sunting profil',
+ 'Change password' => 'Rubah kata sandri',
+ 'Password modification' => 'Modifikasi kata laluan',
+ 'External authentications' => 'Otentifikasi eksternal',
+ 'Never connected.' => 'Tidak pernah terhubung.',
+ 'No external authentication enabled.' => 'Tidak ada otentifikasi eksternal yang aktif.',
+ 'Password modified successfully.' => 'Kata laluan telah berjaya ditukar.',
+ 'Unable to change the password.' => 'Tidak dapat merubah kata laluanr.',
+ 'Change category for the task "%s"' => 'Rubah kategori untuk tugas « %s »',
+ 'Change category' => 'Tukar kategori',
+ '%s updated the task %s' => '%s memperbaharui tugas %s',
+ '%s opened the task %s' => '%s membuka tugas %s',
+ '%s moved the task %s to the position #%d in the column "%s"' => '%s memindahkan tugas %s ke posisi n°%d dalam kolom « %s »',
+ '%s moved the task %s to the column "%s"' => '%s memindahkan tugas %s ke kolom « %s »',
+ '%s created the task %s' => '%s membuat tugas %s',
+ '%s closed the task %s' => '%s menutup tugas %s',
+ '%s created a subtask for the task %s' => '%s membuat subtugas untuk tugas %s',
+ '%s updated a subtask for the task %s' => '%s memperbaharui subtugas untuk tugas %s',
+ 'Assigned to %s with an estimate of %s/%sh' => 'Ditugaskan untuk %s dengan perkiraan %s/%sh',
+ 'Not assigned, estimate of %sh' => 'Tiada yang ditugaskan, perkiraan %sh',
+ '%s updated a comment on the task %s' => '%s memperbaharui komentar pada tugas %s',
+ '%s commented the task %s' => '%s memberikan komentar pada tugas %s',
+ '%s\'s activity' => 'Aktifitas dari %s',
+ 'RSS feed' => 'RSS feed',
+ '%s updated a comment on the task #%d' => '%s memperbaharui komentar pada tugas n°%d',
+ '%s commented on the task #%d' => '%s memberikan komentar pada tugas n°%d',
+ '%s updated a subtask for the task #%d' => '%s memperbaharui subtugas untuk tugas n°%d',
+ '%s created a subtask for the task #%d' => '%s membuat subtugas untuk tugas n°%d',
+ '%s updated the task #%d' => '%s memperbaharui tugas n°%d',
+ '%s created the task #%d' => '%s membuat tugas n°%d',
+ '%s closed the task #%d' => '%s menutup tugas n°%d',
+ '%s open the task #%d' => '%s membuka tugas n°%d',
+ '%s moved the task #%d to the column "%s"' => '%s memindahkan tugas n°%d ke kolom « %s »',
+ '%s moved the task #%d to the position %d in the column "%s"' => '%s memindahkan tugas n°%d ke posisi n°%d dalam kolom « %s »',
+ 'Activity' => 'Aktifitas',
+ 'Default values are "%s"' => 'Standar nilai adalah« %s »',
+ 'Default columns for new projects (Comma-separated)' => 'Kolom default untuk projek baru (dipisahkan dengan koma)',
+ 'Task assignee change' => 'Mengubah orang ditugaskan untuk tugas',
+ '%s change the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s',
+ '%s changed the assignee of the task %s to %s' => '%s mengubah orang yang ditugaskan dari tugas %s ke %s',
+ 'New password for the user "%s"' => 'Kata laluan baru untuk pengguna « %s »',
+ 'Choose an event' => 'Pilih sebuah acara',
+ 'Create a task from an external provider' => 'Buat tugas dari pemasok eksternal',
+ 'Change the assignee based on an external username' => 'Rubah penugasan berdasarkan nama pengguna eksternal',
+ 'Change the category based on an external label' => 'Rubah kategori berdasarkan label eksternal',
+ 'Reference' => 'Referensi',
+ 'Label' => 'Label',
+ 'Database' => 'Pengkalan data',
+ 'About' => 'Tentang',
+ 'Database driver:' => 'Driver pengkalan data:',
+ 'Board settings' => 'Pengaturan papan',
+ 'URL and token' => 'URL dan token',
+ 'Webhook settings' => 'Penetapan webhook',
+ 'URL for task creation:' => 'URL untuk cipta tugas:',
+ 'Reset token' => 'Menetap semula token',
+ 'API endpoint:' => 'API endpoint :',
+ 'Refresh interval for private board' => 'Interval pembaruan untuk papan pribadi',
+ 'Refresh interval for public board' => 'Interval pembaruan untuk papan publik',
+ 'Task highlight period' => 'Periode puncak tugas',
+ 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Periode (dalam detik) untuk mempertimbangkan tugas yang baru dimodifikasi (0 untuk menonaktifkan, standar 2 hari)',
+ 'Frequency in second (60 seconds by default)' => 'Frequensi dalam detik (standar 60 saat)',
+ 'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekuensi dalam detik (0 untuk menonaktifkan fitur ini, standar 10 detik)',
+ 'Application URL' => 'URL Aplikasi',
+ 'Token regenerated.' => 'Token diregenerasi.',
+ 'Date format' => 'Format tarikh',
+ 'ISO format is always accepted, example: "%s" and "%s"' => 'Format ISO selalunya diterima, contoh: « %s » et « %s »',
+ 'New private project' => 'Projek peribadi baharu',
+ 'This project is private' => 'projek ini adalah peribadi',
+ 'Type here to create a new sub-task' => 'Ketik disini untuk membuat sub-tugas baru',
+ 'Add' => 'Tambah',
+ 'Start date' => 'Tarikh mula',
+ 'Time estimated' => 'Anggaran masa',
+ 'There is nothing assigned to you.' => 'Tidak ada yang diberikan kepada anda.',
+ 'My tasks' => 'Tugas saya',
+ 'Activity stream' => 'Arus aktifitas',
+ 'Dashboard' => 'Dasbor',
+ 'Confirmation' => 'Konfirmasi',
+ 'Allow everybody to access to this project' => 'Memungkinkan semua orang untuk mengakses projek ini',
+ 'Everybody have access to this project.' => 'Semua orang mendapat akses untuk projek ini.',
+ 'Webhooks' => 'Webhooks',
+ 'API' => 'API',
+ 'Create a comment from an external provider' => 'Buat komentar dari pemasok eksternal',
+ 'Project management' => 'Manajemen projek',
+ 'My projects' => 'projek saya',
+ 'Columns' => 'Kolom',
+ 'Task' => 'Tugas',
+ 'Your are not member of any project.' => 'Anda bukan anggota dari setiap projek.',
+ 'Percentage' => 'Persentasi',
+ 'Number of tasks' => 'Jumlah dari tugas',
+ 'Task distribution' => 'Pembagian tugas',
+ 'Reportings' => 'Pelaporan',
+ 'Task repartition for "%s"' => 'Pembagian tugas untuk « %s »',
+ 'Analytics' => 'Analitis',
+ 'Subtask' => 'Subtugas',
+ 'My subtasks' => 'Subtugas saya',
+ 'User repartition' => 'Partisi ulang pengguna',
+ 'User repartition for "%s"' => 'Partisi ulang pengguna untuk « %s »',
+ 'Clone this project' => 'Gandakan projek ini',
+ 'Column removed successfully.' => 'Kolom berhasil dihapus.',
+ 'Not enough data to show the graph.' => 'Tidak cukup data untuk menampilkan grafik.',
+ 'Previous' => 'Sebelumnya',
+ 'The id must be an integer' => 'Id harus integer',
+ 'The project id must be an integer' => 'Id projek harus integer',
+ 'The status must be an integer' => 'Status harus integer',
+ 'The subtask id is required' => 'Id subtugas diperlukan',
+ 'The subtask id must be an integer' => 'Id subtugas harus integer',
+ 'The task id is required' => 'Id tugas diperlukan',
+ 'The task id must be an integer' => 'Id tugas harus integer',
+ 'The user id must be an integer' => 'Id user harus integer',
+ 'This value is required' => 'Nilai ini diperlukan',
+ 'This value must be numeric' => 'Nilai ini harus angka',
+ 'Unable to create this task.' => 'Tidak dapat membuat tugas ini',
+ 'Cumulative flow diagram' => 'Diagram alir kumulatif',
+ 'Cumulative flow diagram for "%s"' => 'Diagram alir kumulatif untuk « %s »',
+ 'Daily project summary' => 'Ringkasan projek harian',
+ 'Daily project summary export' => 'Ekspot ringkasan projek harian',
+ 'Daily project summary export for "%s"' => 'Ekspor ringkasan projek harian untuk « %s »',
+ 'Exports' => 'Ekspor',
+ 'This export contains the number of tasks per column grouped per day.' => 'Ekspor ini berisi jumlah dari tugas per kolom dikelompokan perhari.',
+ 'Nothing to preview...' => 'Tiada yang dapat diintai...',
+ 'Preview' => 'Intai',
+ 'Write' => 'Tulis',
+ 'Active swimlanes' => 'Swimlanes aktif',
+ 'Add a new swimlane' => 'Tambah swimlane baharu',
+ 'Change default swimlane' => 'Tukar piawai swimlane',
+ 'Default swimlane' => 'Piawai swimlane',
+ 'Do you really want to remove this swimlane: "%s"?' => 'Anda yakin untuk menghapus swimlane ini : « %s » ?',
+ 'Inactive swimlanes' => 'Swimlanes tidak aktif',
+ 'Remove a swimlane' => 'Padam swimlane',
+ 'Show default swimlane' => 'Tampilkan piawai swimlane',
+ 'Swimlane modification for the project "%s"' => 'Modifikasi swimlane untuk projek « %s »',
+ 'Swimlane not found.' => 'Swimlane tidak ditemui.',
+ 'Swimlane removed successfully.' => 'Swimlane telah dipadamkan.',
+ 'Swimlanes' => 'Swimlanes',
+ 'Swimlane updated successfully.' => 'Swimlane telah dikemaskini.',
+ 'The default swimlane have been updated successfully.' => 'Standar swimlane berhasil diperbaharui.',
+ 'Unable to remove this swimlane.' => 'Tidak dapat menghapus swimlane ini.',
+ 'Unable to update this swimlane.' => 'Tidak dapat memperbaharui swimlane ini.',
+ 'Your swimlane have been created successfully.' => 'Swimlane anda berhasil dibuat.',
+ 'Example: "Bug, Feature Request, Improvement"' => 'Contoh: « Insiden, Permintaan Ciri, Pembaikan »',
+ 'Default categories for new projects (Comma-separated)' => 'Piawaian kategori untuk projek baru (asingkan guna koma)',
+ 'Integrations' => 'Integrasi',
+ 'Integration with third-party services' => 'Integrasi dengan khidmat pihak ketiga',
+ 'Subtask Id' => 'Id Subtugas',
+ 'Subtasks' => 'Subtugas',
+ 'Subtasks Export' => 'Ekspot Subtugas',
+ 'Subtasks exportation for "%s"' => 'Ekspor subtugas untuk « %s »',
+ 'Task Title' => 'Judul Tugas',
+ 'Untitled' => 'Tanpa nama',
+ 'Application default' => 'Aplikasi Piawaian',
+ 'Language:' => 'Bahasa:',
+ 'Timezone:' => 'Zon masa:',
+ 'All columns' => 'Semua kolom',
+ 'Calendar' => 'Kalender',
+ 'Next' => 'Selanjutnya',
+ '#%d' => 'n°%d',
+ 'All swimlanes' => 'Semua swimlane',
+ 'All colors' => 'Semua warna',
+ 'Moved to column %s' => 'Pindah ke kolom %s',
+ 'Change description' => 'Ubah keterangan',
+ 'User dashboard' => 'Papan Kenyataan pengguna',
+ 'Allow only one subtask in progress at the same time for a user' => 'Izinkan hanya satu subtugas dalam proses secara bersamaan untuk satu pengguna',
+ 'Edit column "%s"' => 'Modifikasi kolom « %s »',
+ 'Select the new status of the subtask: "%s"' => 'Pilih status baru untuk subtugas : « %s »',
+ 'Subtask timesheet' => 'Subtugas absen',
+ 'There is nothing to show.' => 'Tidak ada yang dapat diperlihatkan.',
+ 'Time Tracking' => 'Pelacakan waktu',
+ 'You already have one subtask in progress' => 'Anda sudah ada satu subtugas dalam proses',
+ 'Which parts of the project do you want to duplicate?' => 'Bagian dalam projek mana yang ingin anda duplikasi?',
+ 'Disallow login form' => 'Larang formulir masuk',
+ 'Start' => 'Mula',
+ 'End' => 'Selesai',
+ 'Task age in days' => 'Usia tugas dalam bentuk harian',
+ 'Days in this column' => 'Hari dalam kolom ini',
+ '%dd' => '%dj',
+ 'Add a link' => 'Menambahkan pautan',
+ 'Add a new link' => 'Tambah Pautan baru',
+ 'Do you really want to remove this link: "%s"?' => 'Anda yakin akan menghapus Pautan ini : « %s » ?',
+ 'Do you really want to remove this link with task #%d?' => 'Anda yakin akan menghapus Pautan ini dengan tugas n°%d ?',
+ 'Field required' => 'Medan diperlukan',
+ 'Link added successfully.' => 'Pautan berhasil ditambahkan.',
+ 'Link updated successfully.' => 'Pautan berhasil diperbaharui.',
+ 'Link removed successfully.' => 'Pautan berhasil dihapus.',
+ 'Link labels' => 'Label Pautan',
+ 'Link modification' => 'Modifikasi Pautan',
+ 'Links' => 'Pautan',
+ 'Link settings' => 'Pengaturan Pautan',
+ 'Opposite label' => 'Label berlawanan',
+ 'Remove a link' => 'Hapus Pautan',
+ 'Task\'s links' => 'Pautan tugas',
+ 'The labels must be different' => 'Label harus berbeda',
+ 'There is no link.' => 'Tidak ada Pautan.',
+ 'This label must be unique' => 'Label ini harus unik',
+ 'Unable to create your link.' => 'Tidak dapat membuat Pautan anda.',
+ 'Unable to update your link.' => 'Tidak dapat memperbaharui Pautan anda.',
+ 'Unable to remove this link.' => 'Tidak dapat menghapus Pautan ini.',
+ 'relates to' => 'berhubungan dengan',
+ 'blocks' => 'blok',
+ 'is blocked by' => 'diblokir oleh',
+ 'duplicates' => 'duplikat',
+ 'is duplicated by' => 'diduplikasi oleh',
+ 'is a child of' => 'anak dari',
+ 'is a parent of' => 'orant tua dari',
+ 'targets milestone' => 'milestone target',
+ 'is a milestone of' => 'adalah milestone dari',
+ 'fixes' => 'perbaikan',
+ 'is fixed by' => 'diperbaiki oleh',
+ 'This task' => 'Tugas ini',
+ '<1h' => '<1h',
+ '%dh' => '%dh',
+ 'Expand tasks' => 'Perluas tugas',
+ 'Collapse tasks' => 'Lipat tugas',
+ 'Expand/collapse tasks' => 'Perluas/lipat tugas',
+ 'Close dialog box' => 'Tutup kotak dialog',
+ 'Submit a form' => 'Submit formulir',
+ 'Board view' => 'Table halaman',
+ 'Keyboard shortcuts' => 'pintas keyboard',
+ 'Open board switcher' => 'Buka table switcher',
+ 'Application' => 'Aplikasi',
+ 'Compact view' => 'Tampilan kompak',
+ 'Horizontal scrolling' => 'Horisontal bergulir',
+ 'Compact/wide view' => 'Beralih antara tampilan kompak dan diperluas',
+ 'No results match:' => 'Tidak ada hasil :',
+ 'Currency' => 'Mata uang',
+ 'Private project' => 'projek pribadi',
+ 'AUD - Australian Dollar' => 'AUD - Dollar Australia',
+ 'CAD - Canadian Dollar' => 'CAD - Dollar Kanada',
+ 'CHF - Swiss Francs' => 'CHF - Swiss Prancis',
+ 'Custom Stylesheet' => 'Kustomisasi Stylesheet',
+ 'download' => 'unduh',
+ 'EUR - Euro' => 'EUR - Euro',
+ 'GBP - British Pound' => 'GBP - Poundsterling inggris',
+ 'INR - Indian Rupee' => 'INR - Rupe India',
+ 'JPY - Japanese Yen' => 'JPY - Yen Jepang',
+ 'NZD - New Zealand Dollar' => 'NZD - Dollar Selandia baru',
+ 'RSD - Serbian dinar' => 'RSD - Dinar Serbia',
+ 'USD - US Dollar' => 'USD - Dollar Amerika',
+ 'Destination column' => 'Kolom tujuan',
+ 'Move the task to another column when assigned to a user' => 'Pindahkan tugas ke kolom lain ketika ditugaskan ke pengguna',
+ 'Move the task to another column when assignee is cleared' => 'Pindahkan tugas ke kolom lain ketika orang yang ditugaskan dibersihkan',
+ 'Source column' => 'Sumber kolom',
+ 'Transitions' => 'Transisi',
+ 'Executer' => 'Eksekusi',
+ 'Time spent in the column' => 'Waktu yang dihabiskan dalam kolom',
+ 'Task transitions' => 'Transisi tugas',
+ 'Task transitions export' => 'Ekspor transisi tugas',
+ 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Laporan ini berisi semua kolom yang pindah untuk setiap tugas dengan tanggal, pengguna dan waktu yang dihabiskan untuk setiap transisi.',
+ 'Currency rates' => 'Nilai tukar mata uang',
+ 'Rate' => 'Tarif',
+ 'Change reference currency' => 'Mengubah referensi mata uang',
+ 'Add a new currency rate' => 'Tambahkan nilai tukar mata uang baru',
+ 'Reference currency' => 'Referensi mata uang',
+ 'The currency rate have been added successfully.' => 'Nilai tukar mata uang berhasil ditambahkan.',
+ 'Unable to add this currency rate.' => 'Tidak dapat menambahkan nilai tukar mata uang',
+ 'Webhook URL' => 'URL webhook',
+ '%s remove the assignee of the task %s' => '%s menghapus penugasan dari tugas %s',
+ 'Enable Gravatar images' => 'Mengaktifkan gambar Gravatar',
+ 'Information' => 'Informasi',
+ 'Check two factor authentication code' => 'Cek dua faktor kode otentifikasi',
+ 'The two factor authentication code is not valid.' => 'Kode dua faktor kode otentifikasi tidak valid.',
+ 'The two factor authentication code is valid.' => 'Kode dua faktor kode otentifikasi valid.',
+ 'Code' => 'Kode',
+ 'Two factor authentication' => 'Dua faktor otentifikasi',
+ 'This QR code contains the key URI: ' => 'kode QR ini mengandung kunci URI : ',
+ 'Check my code' => 'Memeriksa kode saya',
+ 'Secret key: ' => 'Kunci rahasia : ',
+ 'Test your device' => 'Menguji perangkat anda',
+ 'Assign a color when the task is moved to a specific column' => 'Menetapkan warna ketika tugas tersebut dipindahkan ke kolom tertentu',
+ '%s via Kanboard' => '%s via Kanboard',
+ 'Burndown chart for "%s"' => 'Grafik Burndown untku « %s »',
+ 'Burndown chart' => 'Grafik Burndown',
+ 'This chart show the task complexity over the time (Work Remaining).' => 'Grafik ini menunjukkan kompleksitas tugas dari waktu ke waktu (Sisa Pekerjaan).',
+ 'Screenshot taken %s' => 'Screenshot diambil %s',
+ 'Add a screenshot' => 'Tambah screenshot',
+ // 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
+ 'Screenshot uploaded successfully.' => 'Screenshot berhasil diunggah.',
+ 'SEK - Swedish Krona' => 'SEK - Krona Swedia',
+ 'Identifier' => 'Identifier',
+ 'Disable two factor authentication' => 'Matikan dua faktor otentifikasi',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Apakah anda yakin akan mematikan dua faktor otentifikasi untuk pengguna ini : « %s » ?',
+ 'Edit link' => 'Modifikasi Pautan',
+ 'Start to type task title...' => 'Mulai mengetik judul tugas...',
+ 'A task cannot be linked to itself' => 'Sebuah tugas tidak dapat dikaitkan dengan dirinya sendiri',
+ 'The exact same link already exists' => 'Pautan yang sama persis sudah ada',
+ 'Recurrent task is scheduled to be generated' => 'Tugas berulang dijadwalkan akan dihasilkan',
+ 'Score' => 'Skor',
+ 'The identifier must be unique' => 'Identifier harus unik',
+ 'This linked task id doesn\'t exists' => 'Id tugas terkait tidak ada',
+ 'This value must be alphanumeric' => 'Nilai harus alfanumerik',
+ 'Edit recurrence' => 'Modifikasi pengulangan',
+ 'Generate recurrent task' => 'Menghasilkan tugas berulang',
+ 'Trigger to generate recurrent task' => 'Memicu untuk menghasilkan tugas berulang',
+ 'Factor to calculate new due date' => 'Faktor untuk menghitung tanggal jatuh tempo baru',
+ 'Timeframe to calculate new due date' => 'Jangka waktu untuk menghitung tanggal jatuh tempo baru',
+ 'Base date to calculate new due date' => 'Tanggal dasar untuk menghitung tanggal jatuh tempo baru',
+ 'Action date' => 'Tanggal aksi',
+ 'Base date to calculate new due date: ' => 'Tanggal dasar untuk menghitung tanggal jatuh tempo baru: ',
+ 'This task has created this child task: ' => 'Tugas ini telah menciptakan tugas anak ini: ',
+ 'Day(s)' => 'Hari',
+ 'Existing due date' => 'Batas waktu yang ada',
+ 'Factor to calculate new due date: ' => 'Faktor untuk menghitung tanggal jatuh tempo baru: ',
+ 'Month(s)' => 'Bulan',
+ 'Recurrence' => 'Pengulangan',
+ 'This task has been created by: ' => 'Tugas ini telah dibuat oleh:',
+ 'Recurrent task has been generated:' => 'Tugas berulang telah dihasilkan:',
+ 'Timeframe to calculate new due date: ' => 'Jangka waktu untuk menghitung tanggal jatuh tempo baru: ',
+ 'Trigger to generate recurrent task: ' => 'Pemicu untuk menghasilkan tugas berulang: ',
+ 'When task is closed' => 'Ketika tugas ditutup',
+ 'When task is moved from first column' => 'Ketika tugas dipindahkan dari kolom pertama',
+ 'When task is moved to last column' => 'Ketika tugas dipindahkan ke kolom terakhir',
+ 'Year(s)' => 'Tahun',
+ 'Calendar settings' => 'Pengaturan kalender',
+ 'Project calendar view' => 'Tampilan kalender projek',
+ 'Project settings' => 'Pengaturan projek',
+ 'Show subtasks based on the time tracking' => 'Tampilkan subtugas berdasarkan pelacakan waktu',
+ 'Show tasks based on the creation date' => 'Tampilkan tugas berdasarkan tanggal pembuatan',
+ 'Show tasks based on the start date' => 'Tampilkan tugas berdasarkan tanggal mulai',
+ 'Subtasks time tracking' => 'Pelacakan waktu subtgas',
+ 'User calendar view' => 'Pengguna tampilan kalender',
+ 'Automatically update the start date' => 'Otomatikkan pengemaskinian tanggal',
+ 'iCal feed' => 'iCal feed',
+ 'Preferences' => 'Keutamaan',
+ 'Security' => 'Keamanan',
+ 'Two factor authentication disabled' => 'Otentifikasi dua faktor dimatikan',
+ 'Two factor authentication enabled' => 'Otentifikasi dua faktor dihidupkan',
+ 'Unable to update this user.' => 'Tidak dapat memperbarui pengguna ini.',
+ 'There is no user management for private projects.' => 'Tidak ada manajemen pengguna untuk projek-projek pribadi.',
+ 'User that will receive the email' => 'Pengguna yang akan menerima email',
+ 'Email subject' => 'Subjek Emel',
+ 'Date' => 'Tanggal',
+ 'Add a comment log when moving the task between columns' => 'Menambahkan log komentar ketika memindahkan tugas antara kolom',
+ 'Move the task to another column when the category is changed' => 'Pindahkan tugas ke kolom lain ketika kategori berubah',
+ 'Send a task by email to someone' => 'Kirim tugas melalui email ke seseorang',
+ 'Reopen a task' => 'Membuka kembali tugas',
+ 'Column change' => 'Kolom berubah',
+ 'Position change' => 'Posisi berubah',
+ 'Swimlane change' => 'Swimlane berubah',
+ 'Assignee change' => 'Penerima berubah',
+ '[%s] Overdue tasks' => '[%s] Tugas terlambat',
+ 'Notification' => 'Pemberitahuan',
+ '%s moved the task #%d to the first swimlane' => '%s memindahkan tugas n°%d ke swimlane pertama',
+ '%s moved the task #%d to the swimlane "%s"' => '%s memindahkan tugas n°%d ke swimlane « %s »',
+ 'Swimlane' => 'Swimlane',
+ 'Gravatar' => 'Gravatar',
+ '%s moved the task %s to the first swimlane' => '%s memindahkan tugas %s ke swimlane pertama',
+ '%s moved the task %s to the swimlane "%s"' => '%s memindahkan tugas %s ke swimlane « %s »',
+ 'This report contains all subtasks information for the given date range.' => 'Laporan ini berisi semua informasi subtugas untuk rentang tanggal tertentu.',
+ 'This report contains all tasks information for the given date range.' => 'Laporan ini berisi semua informasi tugas untuk rentang tanggal tertentu.',
+ 'Project activities for %s' => 'Aktifitas projek untuk « %s »',
+ 'view the board on Kanboard' => 'lihat papan di Kanboard',
+ 'The task have been moved to the first swimlane' => 'Tugas telah dipindahkan ke swimlane pertama',
+ 'The task have been moved to another swimlane:' => 'Tugas telah dipindahkan ke swimlane lain:',
+ 'Overdue tasks for the project "%s"' => 'Tugas terlambat untuk projek « %s »',
+ 'New title: %s' => 'Judul baru : %s',
+ 'The task is not assigned anymore' => 'Tugas tidak ditugaskan lagi',
+ 'New assignee: %s' => 'Penerima baru : %s',
+ 'There is no category now' => 'Tidak ada kategori untuk sekarang',
+ 'New category: %s' => 'Kategori baru : %s',
+ 'New color: %s' => 'Warna baru : %s',
+ 'New complexity: %d' => 'Kompleksitas baru : %d',
+ 'The due date have been removed' => 'Tanggal jatuh tempo telah dihapus',
+ 'There is no description anymore' => 'Tidak ada deskripsi lagi',
+ 'Recurrence settings have been modified' => 'Pengaturan pengulangan telah dimodifikasi',
+ 'Time spent changed: %sh' => 'Waktu yang dihabiskan berubah : %sh',
+ 'Time estimated changed: %sh' => 'Perkiraan waktu berubah : %sh',
+ 'The field "%s" have been updated' => 'Field « %s » telah diperbaharui',
+ 'The description have been modified' => 'Deskripsi telah dimodifikasi',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'Apakah anda yakin akan menutup tugas « %s » beserta semua sub-tugasnya ?',
+ 'I want to receive notifications for:' => 'Saya ingin menerima pemberitahuan untuk :',
+ 'All tasks' => 'Semua tugas',
+ 'Only for tasks assigned to me' => 'Hanya untuk tugas yang ditugaskan ke saya',
+ 'Only for tasks created by me' => 'Hanya untuk tugas yang dibuat oleh saya',
+ 'Only for tasks created by me and assigned to me' => 'Hanya untuk tugas yang dibuat oleh saya dan ditugaskan ke saya',
+ '%%Y-%%m-%%d' => '%%d/%%m/%%Y',
+ 'Total for all columns' => 'Total untuk semua kolom',
+ 'You need at least 2 days of data to show the chart.' => 'Anda memerlukan setidaknya 2 hari dari data yang menunjukkan grafik.',
+ '<15m' => '<15m',
+ '<30m' => '<30m',
+ 'Stop timer' => 'Hentikan timer',
+ 'Start timer' => 'Mulai timer',
+ 'Add project member' => 'Tambahkan anggota projek',
+ 'Enable notifications' => 'Aktifkan pemberitahuan',
+ 'My activity stream' => 'Aliran kegiatan saya',
+ 'My calendar' => 'Kalender saya',
+ 'Search tasks' => 'Cari tugas',
+ 'Back to the calendar' => 'Kembali ke kalender',
+ 'Filters' => 'Filter',
+ 'Reset filters' => 'Reset ulang filter',
+ 'My tasks due tomorrow' => 'Tugas saya yang berakhir besok',
+ 'Tasks due today' => 'Tugas yang berakhir hari ini',
+ 'Tasks due tomorrow' => 'Tugas yang berakhir besok',
+ 'Tasks due yesterday' => 'Tugas yang berakhir kemarin',
+ 'Closed tasks' => 'Tugas yang ditutup',
+ 'Open tasks' => 'Buka Tugas',
+ 'Not assigned' => 'Tidak ditugaskan',
+ 'View advanced search syntax' => 'Lihat sintaks pencarian lanjutan',
+ 'Overview' => 'Ikhtisar',
+ 'Board/Calendar/List view' => 'Tampilan Papan/Kalender/Daftar',
+ 'Switch to the board view' => 'Beralih ke tampilan papan',
+ 'Switch to the calendar view' => 'Beralih ke tampilan kalender',
+ 'Switch to the list view' => 'Beralih ke tampilan daftar',
+ 'Go to the search/filter box' => 'Pergi ke kotak pencarian/filter',
+ 'There is no activity yet.' => 'Tidak ada aktifitas saat ini.',
+ 'No tasks found.' => 'Tidak ada tugas yang ditemukan.',
+ 'Keyboard shortcut: "%s"' => 'Keyboard shortcut : « %s »',
+ 'List' => 'Daftar',
+ 'Filter' => 'Filter',
+ 'Advanced search' => 'Pencarian lanjutan',
+ 'Example of query: ' => 'Contoh dari query : ',
+ 'Search by project: ' => 'Pencarian berdasarkan projek : ',
+ 'Search by column: ' => 'Pencarian berdasarkan kolom : ',
+ 'Search by assignee: ' => 'Pencarian berdasarkan penerima : ',
+ 'Search by color: ' => 'Pencarian berdasarkan warna : ',
+ 'Search by category: ' => 'Pencarian berdasarkan kategori : ',
+ 'Search by description: ' => 'Pencarian berdasarkan deskripsi : ',
+ 'Search by due date: ' => 'Pencarian berdasarkan tanggal jatuh tempo : ',
+ 'Lead and Cycle time for "%s"' => 'Memimpin dan Siklus waktu untuk « %s »',
+ 'Average time spent into each column for "%s"' => 'Rata-rata waktu yang dihabiskan dalam setiap kolom untuk « %s »',
+ 'Average time spent into each column' => 'Rata-rata waktu yang dihabiskan dalam setiap kolom',
+ 'Average time spent' => 'Rata-rata waktu yang dihabiskan',
+ 'This chart show the average time spent into each column for the last %d tasks.' => 'Grafik ini menunjukkan rata-rata waktu yang dihabiskan dalam setiap kolom untuk %d tugas.',
+ 'Average Lead and Cycle time' => 'Rata-rata Memimpin dan Siklus waktu',
+ 'Average lead time: ' => 'Rata-rata waktu pimpinan : ',
+ 'Average cycle time: ' => 'Rata-rata siklus waktu : ',
+ 'Cycle Time' => 'Siklus Waktu',
+ 'Lead Time' => 'Lead Time',
+ 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Grafik ini menunjukkan memimpin rata-rata dan waktu siklus untuk %d tugas terakhir dari waktu ke waktu.',
+ 'Average time into each column' => 'Rata-rata waktu ke setiap kolom',
+ 'Lead and cycle time' => 'Lead dan siklus waktu',
+ 'Lead time: ' => 'Lead time : ',
+ 'Cycle time: ' => 'Siklus waktu : ',
+ 'Time spent into each column' => 'Waktu yang dihabiskan di setiap kolom',
+ 'The lead time is the duration between the task creation and the completion.' => 'Lead time adalah durasi antara pembuatan tugas dan penyelesaian.',
+ 'The cycle time is the duration between the start date and the completion.' => 'Siklus waktu adalah durasi antara tanggal mulai dan tanggal penyelesaian.',
+ 'If the task is not closed the current time is used instead of the completion date.' => 'Jika tugas tidak ditutup waktu saat ini yang digunakan sebagai pengganti tanggal penyelesaian.',
+ 'Set automatically the start date' => 'Secara otomatis mengatur tanggal mulai',
+ 'Edit Authentication' => 'Modifikasi Otentifikasi',
+ 'Remote user' => 'Pengguna jauh',
+ 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Pengguna jauh tidak menyimpan kata laluan mereka dalam basis data Kanboard, contoh: Akaun LDAP, Google dan Github.',
+ 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Jika anda mencentang kotak "Larang formulir login", kredensial masuk ke formulis login akan diabaikan.',
+ 'New remote user' => 'Pengguna baru jauh',
+ 'New local user' => 'Pengguna baru lokal',
+ 'Default task color' => 'Standar warna tugas',
+ 'This feature does not work with all browsers.' => 'Ciri ini tidak dapat digunakan pada semua browsers',
+ 'There is no destination project available.' => 'Tiada destinasi projek yang tersedia.',
+ 'Trigger automatically subtask time tracking' => 'Picu pengesanan subtugas secara otomatik',
+ 'Include closed tasks in the cumulative flow diagram' => 'Termasuk tugas yang ditutup pada diagram aliran kumulatif',
+ 'Current swimlane: %s' => 'Swimlane saat ini : %s',
+ 'Current column: %s' => 'Kolom saat ini : %s',
+ 'Current category: %s' => 'Kategori saat ini : %s',
+ 'no category' => 'tiada kategori',
+ 'Current assignee: %s' => 'Saat ini ditugaskan pada: %s',
+ 'not assigned' => 'Belum ditugaskan',
+ 'Author:' => 'Penulis:',
+ 'contributors' => 'Penggiat',
+ 'License:' => 'Lesen:',
+ 'License' => 'Lesen',
+ 'Enter the text below' => 'Masukkan teks di bawah',
+ 'Gantt chart for %s' => 'Carta Gantt untuk %s',
+ 'Sort by position' => 'Urutkan berdasarkan posisi',
+ 'Sort by date' => 'Urutkan berdasarkan tanggal',
+ 'Add task' => 'Tambah tugas',
+ 'Start date:' => 'Tanggal mulai:',
+ 'Due date:' => 'Batas waktu:',
+ 'There is no start date or due date for this task.' => 'Tiada tanggal mulai dan batas waktu untuk tugas ini.',
+ 'Moving or resizing a task will change the start and due date of the task.' => 'Memindahkan atau mengubah ukuran tugas anda akan mengubah tanggal mulai dan batas waktu dari tugas ini.',
+ 'There is no task in your project.' => 'Tiada tugas didalam projek anda.',
+ 'Gantt chart' => 'Carta Gantt',
+ 'People who are project managers' => 'Orang-orang yang menjadi pengurus projek',
+ 'People who are project members' => 'Orang-orang yang menjadi anggota projek',
+ 'NOK - Norwegian Krone' => 'NOK - Krone Norwegia',
+ 'Show this column' => 'Perlihatkan kolom ini',
+ 'Hide this column' => 'Sembunyikan kolom ini',
+ 'open file' => 'buka fail',
+ 'End date' => 'Waktu berakhir',
+ 'Users overview' => 'Ikhtisar pengguna',
+ 'Members' => 'Anggota',
+ 'Shared project' => 'projek bersama',
+ 'Project managers' => 'Pengurus projek',
+ 'Gantt chart for all projects' => 'Carta Gantt untuk kesemua projek',
+ 'Projects list' => 'Senarai projek',
+ 'Gantt chart for this project' => 'Carta Gantt untuk projek ini',
+ 'Project board' => 'Papan projek',
+ 'End date:' => 'Waktu berakhir :',
+ 'There is no start date or end date for this project.' => 'Tidak ada waktu mula atau waktu berakhir pada projek ini',
+ 'Projects Gantt chart' => 'projekkan carta Gantt',
+ 'Link type' => 'Jenis pautan',
+ 'Change task color when using a specific task link' => 'Rubah warna tugas ketika menggunakan Pautan tugas yang spesifik',
+ 'Task link creation or modification' => 'Pautan tugas pada penciptaan atau penyuntingan',
+ 'Milestone' => 'Batu Tanda',
+ 'Documentation: %s' => 'Dokumentasi : %s',
+ 'Switch to the Gantt chart view' => 'Beralih ke tampilan Carta Gantt',
+ 'Reset the search/filter box' => 'Tetap semula pencarian/saringan',
+ 'Documentation' => 'Dokumentasi',
+ 'Table of contents' => 'Isi kandungan',
+ 'Gantt' => 'Gantt',
+ // 'Author' => '',
+ // 'Version' => '',
+ // 'Plugins' => '',
+ // 'There is no plugin loaded.' => '',
+ // 'Set maximum column height' => '',
+ // 'Remove maximum column height' => '',
+ // 'My notifications' => '',
+ // 'Custom filters' => '',
+ // 'Your custom filter have been created successfully.' => '',
+ // 'Unable to create your custom filter.' => '',
+ // 'Custom filter removed successfully.' => '',
+ // 'Unable to remove this custom filter.' => '',
+ // 'Edit custom filter' => '',
+ // 'Your custom filter have been updated successfully.' => '',
+ // 'Unable to update custom filter.' => '',
+ // 'Web' => '',
+ // 'New attachment on task #%d: %s' => '',
+ // 'New comment on task #%d' => '',
+ // 'Comment updated on task #%d' => '',
+ // 'New subtask on task #%d' => '',
+ // 'Subtask updated on task #%d' => '',
+ // 'New task #%d: %s' => '',
+ // 'Task updated #%d' => '',
+ // 'Task #%d closed' => '',
+ // 'Task #%d opened' => '',
+ // 'Column changed for task #%d' => '',
+ // 'New position for task #%d' => '',
+ // 'Swimlane changed for task #%d' => '',
+ // 'Assignee changed on task #%d' => '',
+ // '%d overdue tasks' => '',
+ // 'Task #%d is overdue' => '',
+ // 'No new notifications.' => '',
+ // 'Mark all as read' => '',
+ // 'Mark as read' => '',
+ // 'Total number of tasks in this column across all swimlanes' => '',
+ // 'Collapse swimlane' => '',
+ // 'Expand swimlane' => '',
+ // 'Add a new filter' => '',
+ // 'Share with all project members' => '',
+ // 'Shared' => '',
+ // 'Owner' => '',
+ // 'Unread notifications' => '',
+ // 'My filters' => '',
+ // 'Notification methods:' => '',
+ // 'Import tasks from CSV file' => '',
+ // 'Unable to read your file' => '',
+ // '%d task(s) have been imported successfully.' => '',
+ // 'Nothing have been imported!' => '',
+ // 'Import users from CSV file' => '',
+ // '%d user(s) have been imported successfully.' => '',
+ // 'Comma' => '',
+ // 'Semi-colon' => '',
+ // 'Tab' => '',
+ // 'Vertical bar' => '',
+ // 'Double Quote' => '',
+ // 'Single Quote' => '',
+ // '%s attached a file to the task #%d' => '',
+ // 'There is no column or swimlane activated in your project!' => '',
+ // 'Append filter (instead of replacement)' => '',
+ // 'Append/Replace' => '',
+ // 'Append' => '',
+ // 'Replace' => '',
+ // 'Import' => '',
+ // 'change sorting' => '',
+ // 'Tasks Importation' => '',
+ // 'Delimiter' => '',
+ // 'Enclosure' => '',
+ // 'CSV File' => '',
+ // 'Instructions' => '',
+ // 'Your file must use the predefined CSV format' => '',
+ // 'Your file must be encoded in UTF-8' => '',
+ // 'The first row must be the header' => '',
+ // 'Duplicates are not verified for you' => '',
+ // 'The due date must use the ISO format: YYYY-MM-DD' => '',
+ // 'Download CSV template' => '',
+ // 'No external integration registered.' => '',
+ // 'Duplicates are not imported' => '',
+ // 'Usernames must be lowercase and unique' => '',
+ // 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ 'Role:' => 'Peranan',
+ 'Project members' => 'Anggota projek',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ 'Creation' => 'Ciptaan',
+ 'Expiration' => 'Jangka hayat',
+ 'Password reset history' => 'Sirah tetap semula kata laluan',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
+);
diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php
index 598a15c9..229e4994 100644
--- a/app/Locale/nb_NO/translations.php
+++ b/app/Locale/nb_NO/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Fjern prosjekt',
'Edit the board for "%s"' => 'Endre prosjektsiden for "%s"',
'All projects' => 'Alle prosjekter',
- 'Change columns' => 'Endre kolonner',
'Add a new column' => 'Legg til en ny kolonne',
'Title' => 'Tittel',
'Nobody assigned' => 'Ikke tildelt',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Åpne en oppgave',
'Do you really want to open this task: "%s"?' => 'Vil du åpne denne oppgaven: "%s"?',
'Back to the board' => 'Tilbake til prosjektsiden',
- 'Created on %B %e, %Y at %k:%M %p' => 'Opprettet %d.%m.%Y - %H:%M',
'There is nobody assigned' => 'Mangler tildeling',
'Column on the board:' => 'Kolonne:',
- 'Status is open' => 'Status: åpen',
- 'Status is closed' => 'Status: lukket',
'Close this task' => 'Lukk oppgaven',
'Open this task' => 'Åpne denne oppgaven',
'There is no description.' => 'Det er ingen beskrivelse.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Id\'en er pøøkrevet',
'The project id is required' => 'Prosjektet-id er påkrevet',
'The project name is required' => 'Prosjektnavn er påkrevet',
- 'This project must be unique' => 'Prosjektnavnet skal være unikt',
'The title is required' => 'Tittel er pårevet',
'Settings saved successfully.' => 'Innstillinger lagret.',
'Unable to save your settings.' => 'Innstillinger kunne ikke lagres.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Under arbeid',
'Done' => 'Utført',
'Application version:' => 'Versjon:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Fullført %d.%m.%Y - %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d.%m.%Y - %H:%M',
- 'Date created' => 'Dato for opprettelse',
- 'Date completed' => 'Dato for fullført',
'Id' => 'ID',
'%d closed tasks' => '%d lukkede oppgaver',
'No task for this project' => 'Ingen oppgaver i dette prosjektet',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Kompleksitet',
'Task limit' => 'Oppgave begrensning',
'Task count' => 'Antall oppgaver',
- 'Edit project access list' => 'Endre tillatelser for prosjektet',
- 'Allow this user' => 'Tillat denne brukeren',
- 'Don\'t forget that administrators have access to everything.' => 'Husk at administratorer har tilgang til alt.',
- 'Revoke' => 'Fjern',
- 'List of authorized users' => 'Liste over autoriserte brukere',
'User' => 'Bruker',
- 'Nobody have access to this project.' => 'Ingen har tilgang til dette prosjektet.',
'Comments' => 'Kommentarer',
'Write your text in Markdown' => 'Skriv din tekst i markdown',
'Leave a comment' => 'Legg inn en kommentar',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Rediger oppgaven',
'Due Date' => 'Forfallsdato',
'Invalid date' => 'Ugyldig dato',
- 'Must be done before %B %e, %Y' => 'Skal være utført innen %d.%m.%Y',
- '%B %e, %Y' => '%d.%m.%Y',
- // '%b %e, %Y' => '',
'Automatic actions' => 'Automatiske handlinger',
'Your automatic action have been created successfully.' => 'Din automatiske handling er opprettet.',
'Unable to create your automatic action.' => 'Din automatiske handling kunne ikke opprettes.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Tildel en farge til en bestemt bruker',
'Column title' => 'Kolonne tittel',
'Position' => 'Posisjon',
- 'Move Up' => 'Flytt opp',
- 'Move Down' => 'Flytt ned',
'Duplicate to another project' => 'Kopier til et annet prosjekt',
'Duplicate' => 'Kopier',
'link' => 'link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Kommentaren ble fjernet.',
'Unable to remove this comment.' => 'Kommentaren kunne ikke fjernes.',
'Do you really want to remove this comment?' => 'Vil du fjerne denne kommentaren?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Kun administrator eller brukeren, som har oprettet kommentaren har adgang til denne siden.',
'Current password for the user "%s"' => 'Aktivt passord for brukeren "%s"',
'The current password is required' => 'Passord er påkrevet',
'Wrong password' => 'Feil passord',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'Epost',
- 'Link my Google Account' => 'Knytt til min Google-konto',
- 'Unlink my Google Account' => 'Fjern knytningen til min Google-konto',
- 'Login with my Google Account' => 'Login med min Google-konto',
- 'Project not found.' => 'Prosjekt ikke funnet.',
'Task removed successfully.' => 'Oppgaven er fjernet.',
'Unable to remove this task.' => 'Oppgaven kunne ikke fjernes.',
'Remove a task' => 'Fjern en oppgave',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maksimum størrelse: ',
'Unable to upload the file.' => 'Filen kunne ikke lastes opp.',
'Display another project' => 'Vis annet prosjekt...',
- // 'Login with my Github Account' => '',
- // 'Link my Github Account' => '',
- // 'Unlink my Github Account' => '',
'Created by %s' => 'Opprettet av %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Sist endret %d.%m.%Y - %H:%M',
'Tasks Export' => 'Oppgave eksport',
'Tasks exportation for "%s"' => 'Oppgaveeksportering for "%s"',
'Start Date' => 'Start-dato',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Jeg vil kun ha varslinger for disse prosjekter:',
'view the task on Kanboard' => 'se oppgaven påhovedsiden',
'Public access' => 'Offentlig tilgang',
- 'User management' => 'Brukere',
'Active tasks' => 'Aktive oppgaver',
'Disable public access' => 'Deaktiver offentlig tilgang',
'Enable public access' => 'Aktiver offentlig tilgang',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Epost:',
'Notifications:' => 'Varslinger:',
'Notifications' => 'Varslinger',
- 'Group:' => 'Gruppe:',
- 'Regular user' => 'Normal bruker',
'Account type:' => 'Konto type:',
'Edit profile' => 'Rediger profil',
'Change password' => 'Endre passord',
'Password modification' => 'Passordendring',
'External authentications' => 'Ekstern godkjenning',
- 'Google Account' => 'Google-konto',
- 'Github Account' => 'GitHub-konto',
'Never connected.' => 'Aldri innlogget.',
- 'No account linked.' => 'Ingen kontoer knyttet.',
- 'Account linked.' => 'Konto knyttet.',
'No external authentication enabled.' => 'Ingen eksterne godkjenninger aktiveret.',
'Password modified successfully.' => 'Passord er endret.',
'Unable to change the password.' => 'Passordet kuenne ikke endres.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s endret ansvarlig for oppgaven %s til %s',
'New password for the user "%s"' => 'Nytt passord for brukeren "%s"',
'Choose an event' => 'Velg en hendelse',
- 'Github commit received' => 'Github forpliktelse mottatt',
- 'Github issue opened' => 'Github problem åpnet',
- 'Github issue closed' => 'Github problem lukket',
- 'Github issue reopened' => 'Github problem gjenåpnet',
- 'Github issue assignee change' => 'Endre ansvarlig for Github problem',
- 'Github issue label change' => 'Endre etikett for Github problem',
'Create a task from an external provider' => 'Oppret en oppgave fra en ekstern tilbyder',
'Change the assignee based on an external username' => 'Endre ansvarlige baseret på et eksternt brukernavn',
'Change the category based on an external label' => 'Endre kategorien basert på en ekstern etikett',
'Reference' => 'Referanse',
- 'Reference: %s' => 'Referanse: %s',
'Label' => 'Etikett',
'Database' => 'Database',
'About' => 'Om',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frekevens i sekunder (60 sekunder som standard)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekvens i sekunder (0 for øt deaktivere denne funksjonen, 10 sekunder som standard)',
'Application URL' => 'Applikasjons URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Eksempel: http://example.kanboard.net/ (bruges til email notifikationer)',
'Token regenerated.' => 'Token regenerert.',
'Date format' => 'Datoformat',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO format er alltid akseptert, eksempelvis: "%s" og "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Dette projektet er privat',
'Type here to create a new sub-task' => 'Skriv her for ø opprette en ny deloppgave',
'Add' => 'Legg til',
- 'Estimated time: %s hours' => 'Estimert tid: %s timer',
- 'Time spent: %s hours' => 'Tid brukt: %s timer',
- 'Started on %B %e, %Y' => 'Startet %d.%m.%Y ',
'Start date' => 'Start dato',
'Time estimated' => 'Tid estimert',
'There is nothing assigned to you.' => 'Ingen er tildelt deg.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Alle har tilgang til dette prosjektet',
// 'Webhooks' => '',
// 'API' => '',
- // 'Github webhooks' => '',
- // 'Help on Github webhooks' => '',
'Create a comment from an external provider' => 'Opprett en kommentar fra en ekstern tilbyder',
- // 'Github issue comment created' => '',
'Project management' => 'Prosjektinnstillinger',
'My projects' => 'Mine prosjekter',
'Columns' => 'Kolonner',
@@ -517,7 +467,6 @@ return array(
// 'User repartition for "%s"' => '',
'Clone this project' => 'Kopier dette prosjektet',
'Column removed successfully.' => 'Kolonne flyttet',
- // 'Github Issue' => '',
// 'Not enough data to show the graph.' => '',
'Previous' => 'Forrige',
// 'The id must be an integer' => '',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Standard svømmebane',
// 'Do you really want to remove this swimlane: "%s"?' => '',
// 'Inactive swimlanes' => '',
- 'Set project manager' => 'Velg prosjektleder',
- 'Set project member' => 'Velg prosjektmedlem',
'Remove a swimlane' => 'Fjern en svømmebane',
- 'Rename' => 'Endre navn',
'Show default swimlane' => 'Vis standard svømmebane',
// 'Swimlane modification for the project "%s"' => '',
'Swimlane not found.' => 'Svømmebane ikke funnet',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Svømmebaner',
'Swimlane updated successfully.' => 'Svømmebane oppdatert',
// '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)' => '',
- // 'Gitlab commit received' => '',
- // 'Gitlab issue opened' => '',
- // 'Gitlab issue closed' => '',
- // 'Gitlab webhooks' => '',
- // 'Help on Gitlab webhooks' => '',
'Integrations' => 'Integrasjoner',
'Integration with third-party services' => 'Integrasjoner med tredje-parts tjenester',
- 'Role for this project' => 'Rolle for dette prosjektet',
- 'Project manager' => 'Prosjektleder',
- 'Project member' => 'Prosjektmedlem',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Prosjektlederen kan endre flere innstillinger for prosjektet enn den en vanlig bruker kan.',
- // 'Gitlab Issue' => '',
'Subtask Id' => 'Deloppgave ID',
'Subtasks' => 'Deloppgaver',
'Subtasks Export' => 'Eksporter deloppgaver',
@@ -603,9 +538,6 @@ return array(
// 'You already have one subtask in progress' => '',
'Which parts of the project do you want to duplicate?' => 'Hvilke deler av dette prosjektet ønsker du å kopiere?',
// 'Disallow login form' => '',
- // 'Bitbucket commit received' => '',
- // 'Bitbucket webhooks' => '',
- // 'Help on Bitbucket webhooks' => '',
'Start' => 'Start',
'End' => 'Slutt',
'Task age in days' => 'Dager siden oppgaven ble opprettet',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Denne oppgaven',
// '<1h' => '',
// '%dh' => '',
- // '%b %e' => '',
'Expand tasks' => 'Utvid oppgavevisning',
'Collapse tasks' => 'Komprimer oppgavevisning',
'Expand/collapse tasks' => 'Utvide/komprimere oppgavevisning',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Hurtigtaster',
// 'Open board switcher' => '',
// 'Application' => '',
- 'since %B %e, %Y at %k:%M %p' => 'siden %B %e, %Y at %k:%M %p',
'Compact view' => 'Kompakt visning',
'Horizontal scrolling' => 'Bla horisontalt',
'Compact/wide view' => 'Kompakt/bred visning',
'No results match:' => 'Ingen resultater',
'Currency' => 'Valuta',
- 'Files' => 'Filer',
- 'Images' => 'Bilder',
'Private project' => 'Privat prosjekt',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
@@ -703,17 +631,12 @@ return array(
// 'The two factor authentication code is valid.' => '',
// 'Code' => '',
'Two factor authentication' => 'Dobbel godkjenning',
- // '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' => 'Endre til en valgt farge hvis en oppgave flyttes til en spesifikk kolonne',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
// 'Burndown chart for "%s"' => '',
// 'Burndown chart' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
'Screenshot uploaded successfully.' => 'Skjermbilde opplastet',
// 'SEK - Swedish Krona' => '',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Prosjektkoden er en alfanumerisk kode som kan brukes for å identifisere prosjektet',
'Identifier' => 'Prosjektkode',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
'Date' => 'Dato',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
'Add a comment log when moving the task between columns' => 'Legg til en kommentar i loggen når en oppgave flyttes mellom kolonnene',
'Move the task to another column when the category is changed' => 'Flytt oppgaven til en annen kolonne når kategorien endres',
'Send a task by email to someone' => 'Send en oppgave på epost til noen',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
'Column change' => 'Endret kolonne',
'Position change' => 'Posisjonsendring',
'Swimlane change' => 'Endret svømmebane',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- 'Swimlane: %s' => 'Svømmebane: %s',
'I want to receive notifications for:' => 'Jeg vil motta varslinger om:',
'All tasks' => 'Alle oppgaver',
'Only for tasks assigned to me' => 'Kun oppgaver som er tildelt meg',
'Only for tasks created by me' => 'Kun oppgaver som er opprettet av meg',
'Only for tasks created by me and assigned to me' => 'Kun oppgaver som er opprettet av meg og tildelt meg',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
'Total for all columns' => 'Totalt for alle kolonner',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Ikke tildelt',
'View advanced search syntax' => 'Vis hjelp for avansert søk ',
'Overview' => 'Oversikt',
- // '%b %e %Y' => '',
'Board/Calendar/List view' => 'Oversikt/kalender/listevisning',
'Switch to the board view' => 'Oversiktsvisning',
'Switch to the calendar view' => 'Kalendevisning',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
'New remote user' => 'Ny eksternbruker',
'New local user' => 'Ny internbruker',
'Default task color' => 'Standard oppgavefarge',
- 'Hide sidebar' => 'Skjul sidemeny',
- 'Expand sidebar' => 'Vis sidemeny',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'bidragsytere',
'License:' => 'Lisens:',
'License' => 'Lisens',
- 'Project Administrator' => 'Prosjektadministrator',
'Enter the text below' => 'Legg inn teksten nedenfor',
'Gantt chart for %s' => 'Gantt skjema for %s',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
'open file' => 'Åpne fil',
'End date' => 'Sluttdato',
'Users overview' => 'Brukeroversikt',
- 'Managers' => 'Ledere',
'Members' => 'Medlemmer',
'Shared project' => 'Delt prosjekt',
'Project managers' => 'Prosjektledere',
- 'Project members' => 'Prosjektmedlemmer',
// 'Gantt chart for all projects' => '',
'Projects list' => 'Prosjektliste',
'Gantt chart for this project' => 'Gantt skjema for dette prosjektet',
@@ -964,26 +854,16 @@ return array(
'End date:' => 'Sluttdato:',
// 'There is no start date or end date for this project.' => '',
'Projects Gantt chart' => 'Gantt skjema for prosjekter',
- 'Start date: %s' => 'Startdato: %s',
- 'End date: %s' => 'Sluttdato: %s',
'Link type' => 'Relasjonstype',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
'Milestone' => 'Milepæl',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
'Documentation: %s' => 'Dokumentasjon: %s',
'Switch to the Gantt chart view' => 'Gantt skjema visning',
'Reset the search/filter box' => 'Nullstill søk/filter',
'Documentation' => 'Dokumentasjon',
'Table of contents' => 'Innholdsfortegnelse',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Hjelp med prosjekttilganger',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ 'Project members' => 'Prosjektmedlemmer',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php
index fd74c977..d1d47a34 100644
--- a/app/Locale/nl_NL/translations.php
+++ b/app/Locale/nl_NL/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Project verwijderen',
'Edit the board for "%s"' => 'Bord bewerken voor « %s »',
'All projects' => 'Alle projecten',
- 'Change columns' => 'Kolommen veranderen',
'Add a new column' => 'Kolom toevoegen',
'Title' => 'Titel',
'Nobody assigned' => 'Niemand toegewezen',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Een taak openen',
'Do you really want to open this task: "%s"?' => 'Weet u zeker dat u deze taak wil openen : « %s » ?',
'Back to the board' => 'Terug naar het bord',
- 'Created on %B %e, %Y at %k:%M %p' => 'Aangemaakt op %d/%m/%Y à %H:%M',
'There is nobody assigned' => 'Er is niemand toegewezen',
'Column on the board:' => 'Kolom op het bord : ',
- 'Status is open' => 'Status is open',
- 'Status is closed' => 'Status is gesloten',
'Close this task' => 'Deze taak sluiten',
'Open this task' => 'Deze taak openen',
'There is no description.' => 'Er is geen omschrijving.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Het id is verplicht',
'The project id is required' => 'Het project id is verplicht',
'The project name is required' => 'De projectnaam is verplicht',
- 'This project must be unique' => 'Dit project moet uniek zijn',
'The title is required' => 'De titel is verplicht',
'Settings saved successfully.' => 'Instellingen succesvol opgeslagen.',
'Unable to save your settings.' => 'Instellingen opslaan niet gelukt.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'In behandeling',
'Done' => 'Afgewerkt',
'Application version:' => 'Applicatie versie :',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Voltooid op %d/%m/%Y à %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d/%m/%Y op %H:%M',
- 'Date created' => 'Datum aangemaakt',
- 'Date completed' => 'Datum voltooid',
'Id' => 'Id',
'%d closed tasks' => '%d gesloten taken',
'No task for this project' => 'Geen taken voor dit project',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Complexiteit',
'Task limit' => 'Taak limiet.',
'Task count' => 'Aantal taken',
- 'Edit project access list' => 'Aanpassen toegangsrechten project',
- 'Allow this user' => 'Deze gebruiker toestaan',
- 'Don\'t forget that administrators have access to everything.' => 'Vergeet niet dat administrators overal toegang hebben.',
- 'Revoke' => 'Intrekken',
- 'List of authorized users' => 'Lijst met geautoriseerde gebruikers',
'User' => 'Gebruiker',
- 'Nobody have access to this project.' => 'Niemand heeft toegang tot dit project',
'Comments' => 'Commentaar',
'Write your text in Markdown' => 'Schrijf uw tekst in Markdown',
'Leave a comment' => 'Schrijf een commentaar',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Deze taak aanpassen',
'Due Date' => 'Vervaldag',
'Invalid date' => 'Ongeldige datum',
- 'Must be done before %B %e, %Y' => 'Moet voltooid zijn voor %d/%m/%Y',
- '%B %e, %Y' => '%d %B %Y',
- '%b %e, %Y' => '%d/%m/%Y',
'Automatic actions' => 'Geautomatiseerd acties',
'Your automatic action have been created successfully.' => 'Geautomatiseerde actie succesvol aangemaakt.',
'Unable to create your automatic action.' => 'Geautomatiseerde actie aanmaken niet gelukt.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Wijs een kleur toe aan een gebruiker',
'Column title' => 'Kolom titel',
'Position' => 'Positie',
- 'Move Up' => 'Omhoog verplaatsen',
- 'Move Down' => 'Omlaag verplaatsen',
'Duplicate to another project' => 'Dupliceren in een ander project',
'Duplicate' => 'Dupliceren',
'link' => 'koppelen',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Commentaar succesvol verwijder.',
'Unable to remove this comment.' => 'Commentaar verwijderen niet gelukt.',
'Do you really want to remove this comment?' => 'Weet u zeker dat u dit commentaar wil verwijderen ?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Alleen administrators of de aanmaker van het commentaar hebben toegang tot deze pagina.',
'Current password for the user "%s"' => 'Huidig wachtwoord voor gebruiker « %s »',
'The current password is required' => 'Huidig wachtwoord is verplicht',
'Wrong password' => 'Onjuist wachtwoord',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'Email',
- 'Link my Google Account' => 'Link mijn Google Account',
- 'Unlink my Google Account' => 'Link met Google Account verwijderen',
- 'Login with my Google Account' => 'Inloggen met mijn Google Account',
- 'Project not found.' => 'Project niet gevonden.',
'Task removed successfully.' => 'Taak succesvol verwijderd.',
'Unable to remove this task.' => 'Taak verwijderen niet gelukt.',
'Remove a task' => 'Taak verwijderen',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maximale grootte : ',
'Unable to upload the file.' => 'Uploaden van bestand niet gelukt.',
'Display another project' => 'Een ander project weergeven',
- 'Login with my Github Account' => 'Login met mijn Github Account',
- 'Link my Github Account' => 'Link met mijn Github',
- 'Unlink my Github Account' => 'Link met mijn Github verwijderen',
'Created by %s' => 'Aangemaakt door %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Laatst gewijzigd op %d/%m/%Y à %H:%M',
'Tasks Export' => 'Taken exporteren',
'Tasks exportation for "%s"' => 'Taken exporteren voor « %s »',
'Start Date' => 'Startdatum',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Ik wil notificaties ontvangen van de volgende projecten :',
'view the task on Kanboard' => 'taak bekijken op Kanboard',
'Public access' => 'Publieke toegang',
- 'User management' => 'Gebruikers management',
'Active tasks' => 'Actieve taken',
'Disable public access' => 'Publieke toegang uitschakelen',
'Enable public access' => 'Publieke toegang inschakelen',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Email :',
'Notifications:' => 'Notificaties :',
'Notifications' => 'Notificaties',
- 'Group:' => 'Groep :',
- 'Regular user' => 'Normale gebruiker',
'Account type:' => 'Account type:',
'Edit profile' => 'Profiel aanpassen',
'Change password' => 'Wachtwoord aanpassen',
'Password modification' => 'Wachtwoord aanpassen',
'External authentications' => 'Externe authenticatie',
- 'Google Account' => 'Google Account',
- 'Github Account' => 'Github Account',
'Never connected.' => 'Nooit verbonden.',
- 'No account linked.' => 'Geen account gelinkt.',
- 'Account linked.' => 'Account gelinkt.',
'No external authentication enabled.' => 'Geen externe authenticatie aangezet.',
'Password modified successfully.' => 'Wachtwoord succesvol aangepast.',
'Unable to change the password.' => 'Aanpassen van wachtwoord niet gelukt.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s heeft de toegewezene voor taak %s veranderd in %s',
'New password for the user "%s"' => 'Nieuw wachtwoord voor gebruiker « %s »',
'Choose an event' => 'Kies een gebeurtenis',
- 'Github commit received' => 'Github commentaar ontvangen',
- 'Github issue opened' => 'Github issue geopend',
- 'Github issue closed' => 'Github issue gesloten',
- 'Github issue reopened' => 'Github issue heropend',
- 'Github issue assignee change' => 'Github toegewezen veranderd',
- 'Github issue label change' => 'Github issue label verander',
'Create a task from an external provider' => 'Maak een taak aan vanuit een externe provider',
'Change the assignee based on an external username' => 'Verander de toegewezene aan de hand van de externe gebruikersnaam',
'Change the category based on an external label' => 'Verander de categorie aan de hand van een extern label',
'Reference' => 'Referentie',
- 'Reference: %s' => 'Referentie : %s',
'Label' => 'Label',
'Database' => 'Database',
'About' => 'Over',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frequentie in seconden (stadaard 60)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frequentie in seconden (0 om uit te schakelen, standaard 10)',
'Application URL' => 'Applicatie URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Voorbeeld: http://example.kanboard.net/ (gebruikt voor email notificaties)',
'Token regenerated.' => 'Token opnieuw gegenereerd.',
'Date format' => 'Datum formaat',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO formaat is altijd geaccepteerd, bijvoorbeeld : « %s » et « %s »',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Dit project is privé',
'Type here to create a new sub-task' => 'Typ hier om een nieuwe subtaak aan te maken',
'Add' => 'Toevoegen',
- 'Estimated time: %s hours' => 'Geschatte tijd: %s hours',
- 'Time spent: %s hours' => 'Tijd besteed : %s heures',
- 'Started on %B %e, %Y' => 'Gestart op %d/%m/%Y',
'Start date' => 'Startdatum',
'Time estimated' => 'Geschatte tijd',
'There is nothing assigned to you.' => 'Er is niets aan u toegewezen.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Iedereen heeft toegang tot dit project.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github webhooks',
- 'Help on Github webhooks' => 'Hulp bij Github webhooks',
'Create a comment from an external provider' => 'Voeg een commentaar toe van een externe provider',
- 'Github issue comment created' => 'Github issue commentaar aangemaakt',
'Project management' => 'Project management',
'My projects' => 'Mijn projecten',
'Columns' => 'Kolommen',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Gebruikerverdeling voor « %s »',
'Clone this project' => 'Kloon dit project',
'Column removed successfully.' => 'Kolom succesvol verwijderd.',
- 'Github Issue' => 'Github issue',
'Not enough data to show the graph.' => 'Niet genoeg data om de grafiek te laten zien.',
// 'Previous' => '',
'The id must be an integer' => 'Het id moet een integer zijn',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Standaard swinlane',
'Do you really want to remove this swimlane: "%s"?' => 'Weet u zeker dat u deze swimlane wil verwijderen : « %s » ?',
'Inactive swimlanes' => 'Inactieve swinlanes',
- 'Set project manager' => 'Project manager instellen',
- 'Set project member' => 'Project lid instellen',
'Remove a swimlane' => 'Verwijder swinlane',
- 'Rename' => 'Hernoemen',
'Show default swimlane' => 'Standaard swimlane tonen',
'Swimlane modification for the project "%s"' => 'Swinlane aanpassing voor project « %s »',
'Swimlane not found.' => 'Swimlane niet gevonden.',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane succesvol aangepast.',
'The default swimlane have been updated successfully.' => 'De standaard swimlane is succesvol aangepast.',
- 'Unable to create your swimlane.' => 'Swimlane aanmaken niet gelukt.',
'Unable to remove this swimlane.' => 'Swimlane verwijderen niet gelukt.',
'Unable to update this swimlane.' => 'Swimlane aanpassen niet gelukt.',
'Your swimlane have been created successfully.' => 'Swimlane succesvol aangemaakt.',
'Example: "Bug, Feature Request, Improvement"' => 'Voorbeeld: « Bug, Feature Request, Improvement »',
'Default categories for new projects (Comma-separated)' => 'Standaard categorieën voor nieuwe projecten (komma gescheiden)',
- 'Gitlab commit received' => 'Gitlab commir ontvangen',
- 'Gitlab issue opened' => 'Gitlab issue geopend',
- 'Gitlab issue closed' => 'Gitlab issue gesloten',
- 'Gitlab webhooks' => 'Gitlab webhooks',
- 'Help on Gitlab webhooks' => 'Hulp bij Gitlab webhooks',
'Integrations' => 'Integraties',
'Integration with third-party services' => 'Integratie met derde-partij-services',
- 'Role for this project' => 'Rol voor dit project',
- 'Project manager' => 'Project manager',
- 'Project member' => 'Project lid',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Een project manager kan de instellingen van het project wijzigen en heeft meer rechten dan een normale gebruiker.',
- 'Gitlab Issue' => 'Gitlab issue',
'Subtask Id' => 'Subtaak id',
'Subtasks' => 'Subtaken',
'Subtasks Export' => 'Subtaken exporteren',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'U heeft al een subtaak in behandeling',
'Which parts of the project do you want to duplicate?' => 'Welke onderdelen van het project wilt u dupliceren?',
// 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Bitbucket commit ontvangen',
- 'Bitbucket webhooks' => 'Bitbucket webhooks',
- 'Help on Bitbucket webhooks' => 'Help bij Bitbucket webhooks',
'Start' => 'Start',
'End' => 'Eind',
'Task age in days' => 'Leeftijd taak in dagen',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Deze taal',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%e %b',
'Expand tasks' => 'Taken uitklappen',
'Collapse tasks' => 'Taken inklappen',
'Expand/collapse tasks' => 'Taken in/uiklappen',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Keyboard snelkoppelingen',
'Open board switcher' => 'Open bord switcher',
'Application' => 'Applicatie',
- 'since %B %e, %Y at %k:%M %p' => 'sinds %d/%m/%Y à %H:%M',
// 'Compact view' => '',
// 'Horizontal scrolling' => '',
// 'Compact/wide view' => '',
// 'No results match:' => '',
// 'Currency' => '',
- // 'Files' => '',
- // 'Images' => '',
// 'Private project' => '',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
@@ -703,17 +631,12 @@ return array(
// 'The two factor authentication code is valid.' => '',
// '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' => '',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
// 'Burndown chart for "%s"' => '',
// 'Burndown chart' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
// 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
// 'Send a task by email to someone' => '',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
// 'Column change' => '',
// 'Position change' => '',
// 'Swimlane change' => '',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
// 'I want to receive notifications for:' => '',
// 'All tasks' => '',
// 'Only for tasks assigned to me' => '',
// 'Only for tasks created by me' => '',
// 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
// 'Not assigned' => '',
// 'View advanced search syntax' => '',
// 'Overview' => '',
- // '%b %e %Y' => '',
// 'Board/Calendar/List view' => '',
// 'Switch to the board view' => '',
// 'Switch to the calendar view' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
// 'New remote user' => '',
// 'New local user' => '',
// 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
// 'contributors' => '',
// 'License:' => '',
// 'License' => '',
- // 'Project Administrator' => '',
// 'Enter the text below' => '',
// 'Gantt chart for %s' => '',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php
index bcc0c433..9caa6b88 100644
--- a/app/Locale/pl_PL/translations.php
+++ b/app/Locale/pl_PL/translations.php
@@ -20,15 +20,15 @@ return array(
'Red' => 'Czerwony',
'Orange' => 'Pomarańczowy',
'Grey' => 'Szary',
- // 'Brown' => '',
- // 'Deep Orange' => '',
- // 'Dark Grey' => '',
- // 'Pink' => '',
- // 'Teal' => '',
- // 'Cyan' => '',
- // 'Lime' => '',
- // 'Light Green' => '',
- // 'Amber' => '',
+ 'Brown' => 'Brąz',
+ 'Deep Orange' => 'Ciemnopomarańczowy',
+ 'Dark Grey' => 'Ciemnoszary',
+ 'Pink' => 'Różowy',
+ 'Teal' => 'Turkusowy',
+ 'Cyan' => 'Cyjan',
+ 'Lime' => 'Limonkowy',
+ 'Light Green' => 'Jasnozielony',
+ 'Amber' => 'Bursztynowy',
'Save' => 'Zapisz',
'Login' => 'Login',
'Official website:' => 'Oficjalna strona:',
@@ -72,9 +72,8 @@ return array(
'Remove project' => 'Usuń projekt',
'Edit the board for "%s"' => 'Edytuj tablicę dla "%s"',
'All projects' => 'Wszystkie projekty',
- 'Change columns' => 'Zmień kolumny',
'Add a new column' => 'Dodaj nową kolumnę',
- 'Title' => 'Tytuł',
+ 'Title' => 'Nazwa',
'Nobody assigned' => 'Nikt nie przypisany',
'Assigned to %s' => 'Przypisane do %s',
'Remove a column' => 'Usuń kolumnę',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Otwórz zadanie',
'Do you really want to open this task: "%s"?' => 'Na pewno chcesz otworzyć zadanie: "%s"?',
'Back to the board' => 'Powrót do tablicy',
- 'Created on %B %e, %Y at %k:%M %p' => 'Utworzono dnia %e %B %Y o %k:%M',
'There is nobody assigned' => 'Nikt nie jest przypisany',
'Column on the board:' => 'Kolumna na tablicy:',
- 'Status is open' => 'Status otwarty',
- 'Status is closed' => 'Status zamknięty',
'Close this task' => 'Zamknij zadanie',
'Open this task' => 'Otwórz zadanie',
'There is no description.' => 'Brak opisu.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'ID jest wymagane',
'The project id is required' => 'ID projektu jest wymagane',
'The project name is required' => 'Nazwa projektu jest wymagana',
- 'This project must be unique' => 'Projekt musi być unikalny',
'The title is required' => 'Tutył jest wymagany',
'Settings saved successfully.' => 'Ustawienia zapisane.',
'Unable to save your settings.' => 'Nie udało się zapisać ustawień.',
@@ -149,7 +144,7 @@ return array(
'Task created successfully.' => 'Zadanie zostało utworzone.',
'User created successfully.' => 'Użytkownik dodany',
'Unable to create your user.' => 'Nie udało się dodać użytkownika.',
- 'User updated successfully.' => 'Użytkownik zaktualizowany.',
+ 'User updated successfully.' => 'Profil użytkownika został zaaktualizowany.',
'Unable to update your user.' => 'Nie udało się zaktualizować użytkownika.',
'User removed successfully.' => 'Użytkownik usunięty.',
'Unable to remove this user.' => 'Nie udało się usunąć użytkownika.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'W trakcie',
'Done' => 'Zakończone',
'Application version:' => 'Wersja aplikacji:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Zakończono dnia %e %B %Y o %k:%M',
- '%B %e, %Y at %k:%M %p' => '%e %B %Y o %k:%M',
- 'Date created' => 'Data utworzenia',
- 'Date completed' => 'Data zakończenia',
'Id' => 'Id',
'%d closed tasks' => '%d zamkniętych zadań',
'No task for this project' => 'Brak zadań dla tego projektu',
@@ -175,16 +166,10 @@ return array(
'Complexity' => 'Poziom trudności',
'Task limit' => 'Limit zadań',
'Task count' => 'Liczba zadań',
- 'Edit project access list' => 'Edycja list dostępu dla projektu',
- 'Allow this user' => 'Dodaj użytkownika',
- 'Don\'t forget that administrators have access to everything.' => 'Pamiętaj: Administratorzy mają zawsze dostęp do wszystkiego!',
- 'Revoke' => 'Odbierz dostęp',
- 'List of authorized users' => 'Lista użytkowników mających dostęp',
'User' => 'Użytkownik',
- 'Nobody have access to this project.' => 'Żaden użytkownik nie ma dostępu do tego projektu',
'Comments' => 'Komentarze',
- 'Write your text in Markdown' => 'Możesz użyć Markdown',
- 'Leave a comment' => 'Zostaw komentarz',
+ 'Write your text in Markdown' => 'Zobacz jak formatować tekst z użyciem Markdown',
+ 'Leave a comment' => 'Wstaw komentarz',
'Comment is required' => 'Komentarz jest wymagany',
'Leave a description' => 'Dodaj opis',
'Comment added successfully.' => 'Komentarz dodany',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Edytuj zadanie',
'Due Date' => 'Termin',
'Invalid date' => 'Błędna data',
- 'Must be done before %B %e, %Y' => 'Termin do %e %B %Y',
- '%B %e, %Y' => '%e %B %Y',
- '%b %e, %Y' => '%e %B %Y',
'Automatic actions' => 'Akcje automatyczne',
'Your automatic action have been created successfully.' => 'Twoja akcja została dodana',
'Unable to create your automatic action.' => 'Nie udało się utworzyć akcji',
@@ -225,18 +207,15 @@ return array(
'Assign a color to a specific user' => 'Przypisz kolor do wybranego użytkownika',
'Column title' => 'Tytuł kolumny',
'Position' => 'Pozycja',
- 'Move Up' => 'Przenieś wyżej',
- 'Move Down' => 'Przenieś niżej',
'Duplicate to another project' => 'Skopiuj do innego projektu',
'Duplicate' => 'Utwórz kopię',
'link' => 'link',
'Comment updated successfully.' => 'Komentarz został zapisany.',
- 'Unable to update your comment.' => 'Nie udało się zapisanie komentarza.',
+ 'Unable to update your comment.' => 'Nie udało się zapisać komentarza.',
'Remove a comment' => 'Usuń komentarz',
'Comment removed successfully.' => 'Komentarz został usunięty.',
'Unable to remove this comment.' => 'Nie udało się usunąć komentarza.',
'Do you really want to remove this comment?' => 'Czy na pewno usunąć ten komentarz?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Tylko administratorzy oraz autor komentarza ma dostęp do tej strony.',
'Current password for the user "%s"' => 'Aktualne hasło dla użytkownika "%s"',
'The current password is required' => 'Wymanage jest aktualne hasło',
'Wrong password' => 'Błędne hasło',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'Email',
- 'Link my Google Account' => 'Połącz z kontem Google',
- 'Unlink my Google Account' => 'Rozłącz z kontem Google',
- 'Login with my Google Account' => 'Zaloguj przy pomocy konta Google',
- 'Project not found.' => 'Projek nieznaleziony.',
'Task removed successfully.' => 'Zadanie usunięto pomyślnie.',
'Unable to remove this task.' => 'Nie można usunąć tego zadania.',
'Remove a task' => 'Usuń zadanie',
@@ -302,7 +277,7 @@ return array(
'Attach a document' => 'Dołącz plik',
'Do you really want to remove this file: "%s"?' => 'Czy na pewno chcesz usunąć plik: "%s"?',
'Attachments' => 'Załączniki',
- 'Edit the task' => 'Edytuj Zadanie',
+ 'Edit the task' => 'Edytuj zadanie',
'Edit the description' => 'Edytuj opis',
'Add a comment' => 'Dodaj komentarz',
'Edit a comment' => 'Edytuj komentarz',
@@ -312,11 +287,11 @@ return array(
'Spent:' => 'Przeznaczony:',
'Do you really want to remove this sub-task?' => 'Czy na pewno chcesz usunąć to pod-zadanie?',
'Remaining:' => 'Pozostało:',
- 'hours' => 'godzin',
+ 'hours' => 'godzin(y)',
'spent' => 'przeznaczono',
'estimated' => 'szacowany',
- 'Sub-Tasks' => 'Pod-zadanie',
- 'Add a sub-task' => 'Dodaj pod-zadanie',
+ 'Sub-Tasks' => 'Podzadania',
+ 'Add a sub-task' => 'Dodaj podzadanie',
'Original estimate' => 'Szacowanie początkowe',
'Create another sub-task' => 'Dodaj kolejne pod-zadanie',
'Time spent' => 'Przeznaczony czas',
@@ -326,19 +301,15 @@ return array(
'Todo' => 'Do zrobienia',
'In progress' => 'W trakcie',
'Sub-task removed successfully.' => 'Pod-zadanie usunięte pomyślnie.',
- 'Unable to remove this sub-task.' => 'Nie można usunąć tego pod-zadania.',
+ 'Unable to remove this sub-task.' => 'Nie można usunąć tego podzadania.',
'Sub-task updated successfully.' => 'Pod-zadanie zaktualizowane pomyślnie.',
- 'Unable to update your sub-task.' => 'Nie można zaktualizować tego pod-zadania.',
- 'Unable to create your sub-task.' => 'Nie można utworzyć tego pod-zadania.',
+ 'Unable to update your sub-task.' => 'Nie można zaktualizować tego podzadania.',
+ 'Unable to create your sub-task.' => 'Nie można utworzyć tego podzadania.',
'Sub-task added successfully.' => 'Pod-zadanie utworzone pomyślnie',
'Maximum size: ' => 'Maksymalny rozmiar: ',
'Unable to upload the file.' => 'Nie można wczytać pliku.',
'Display another project' => 'Wyświetl inny projekt',
- 'Login with my Github Account' => 'Zaloguj przy użyciu konta Github',
- 'Link my Github Account' => 'Podłącz konto Github',
- 'Unlink my Github Account' => 'Odłącz konto Github',
'Created by %s' => 'Utworzone przez %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Ostatnio zmienione %e %B %Y o %k:%M',
'Tasks Export' => 'Eksport zadań',
'Tasks exportation for "%s"' => 'Eksport zadań dla "%s"',
'Start Date' => 'Data początkowa',
@@ -356,7 +327,7 @@ return array(
'The task #%d have been opened.' => 'Zadania #%d zostały otwarte.',
'The task #%d have been closed.' => 'Zadania #%d zostały zamknięte.',
'Sub-task updated' => 'Pod-zadanie zaktualizowane',
- 'Title:' => 'Tytuł:',
+ 'Title:' => 'Nazwa:',
// 'Status:' => '',
'Assignee:' => 'Przypisano do:',
'Time tracking:' => 'Śledzenie czasu: ',
@@ -371,10 +342,9 @@ return array(
'Task updated' => 'Zaktualizowane zadanie',
'Task closed' => 'Zadanie zamknięte',
'Task opened' => 'Zadanie otwarte',
- 'I want to receive notifications only for those projects:' => 'Chcę otrzymywać powiadiomienia tylko dla tych projektów:',
+ 'I want to receive notifications only for those projects:' => 'Chcę otrzymywać powiadomienia tylko dla poniższych projektów:',
'view the task on Kanboard' => 'Zobacz zadanie',
'Public access' => 'Dostęp publiczny',
- 'User management' => 'Zarządzanie użytkownikami',
'Active tasks' => 'Aktywne zadania',
'Disable public access' => 'Zablokuj dostęp publiczny',
'Enable public access' => 'Odblokuj dostęp publiczny',
@@ -392,23 +362,17 @@ return array(
'Remote' => 'Zdalne',
'Enabled' => 'Odblokowane',
'Disabled' => 'Zablokowane',
- 'Username:' => 'Nazwa Użytkownika:',
+ 'Username:' => 'Nazwa Użytkownika (login):',
'Name:' => 'Imię i Nazwisko',
'Email:' => 'Email: ',
'Notifications:' => 'Powiadomienia: ',
'Notifications' => 'Powiadomienia',
- 'Group:' => 'Grupa:',
- 'Regular user' => 'Zwykły użytkownik',
'Account type:' => 'Typ konta:',
'Edit profile' => 'Edytuj profil',
'Change password' => 'Zmień hasło',
'Password modification' => 'Zmiana hasła',
'External authentications' => 'Autentykacja zewnętrzna',
- 'Google Account' => 'Konto Google',
- 'Github Account' => 'Konto Github',
'Never connected.' => 'Nigdy nie połączone.',
- 'No account linked.' => 'Brak połączonych kont.',
- 'Account linked.' => 'Konto połączone.',
'No external authentication enabled.' => 'Brak autentykacji zewnętrznych.',
'Password modified successfully.' => 'Hasło zmienione pomyślne.',
'Unable to change the password.' => 'Nie można zmienić hasła.',
@@ -420,8 +384,8 @@ return array(
'%s moved the task %s to the column "%s"' => '%s przeniósł zadanie %s do kolumny "%s"',
'%s created the task %s' => '%s utworzył zadanie %s',
'%s closed the task %s' => '%s zamknął zadanie %s',
- '%s created a subtask for the task %s' => '%s utworzył pod-zadanie dla zadania %s',
- '%s updated a subtask for the task %s' => '%s zaktualizował pod-zadanie dla zadania %s',
+ '%s created a subtask for the task %s' => '%s utworzył podzadanie dla zadania %s',
+ '%s updated a subtask for the task %s' => '%s zaktualizował podzadanie dla zadania %s',
'Assigned to %s with an estimate of %s/%sh' => 'Przypisano do %s z szacowanym czasem wykonania %s/%sh',
'Not assigned, estimate of %sh' => 'Nie przypisane, szacowany czas wykonania %sh',
'%s updated a comment on the task %s' => '%s zaktualizował komentarz do zadania %s',
@@ -430,8 +394,8 @@ return array(
'RSS feed' => 'Kanał RSS',
'%s updated a comment on the task #%d' => '%s zaktualizował komentarz do zadania #%d',
'%s commented on the task #%d' => '%s skomentował zadanie #%d',
- '%s updated a subtask for the task #%d' => '%s zaktualizował pod-zadanie dla zadania #%d',
- '%s created a subtask for the task #%d' => '%s utworzył pod-zadanie dla zadania #%d',
+ '%s updated a subtask for the task #%d' => '%s zaktualizował podzadanie dla zadania #%d',
+ '%s created a subtask for the task #%d' => '%s utworzył podzadanie dla zadania #%d',
'%s updated the task #%d' => '%s zaktualizował zadanie #%d',
'%s created the task #%d' => '%s utworzył zadanie #%d',
'%s closed the task #%d' => '%s zamknął zadanie #%d',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s zmienił osobę odpowiedzialną za zadanie %s na %s',
'New password for the user "%s"' => 'Nowe hasło użytkownika "%s"',
'Choose an event' => 'Wybierz zdarzenie',
- // 'Github commit received' => '',
- // 'Github issue opened' => '',
- // 'Github issue closed' => '',
- // 'Github issue reopened' => '',
- // 'Github issue assignee change' => '',
- // 'Github issue label change' => '',
'Create a task from an external provider' => 'Utwórz zadanie z dostawcy zewnętrznego',
'Change the assignee based on an external username' => 'Zmień osobę odpowiedzialną na podstawie zewnętrznej nazwy użytkownika',
'Change the category based on an external label' => 'Zmień kategorię na podstawie zewnętrznej etykiety',
// 'Reference' => '',
- // 'Reference: %s' => '',
'Label' => 'Etykieta',
'Database' => 'Baza danych',
'About' => 'Informacje',
@@ -474,36 +431,29 @@ return array(
'Frequency in second (60 seconds by default)' => 'Częstotliwość w sekundach (domyślnie 60 sekund)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Częstotliwość w sekundach (0 aby zablokować, domyślnie 10 sekund)',
'Application URL' => 'Adres URL aplikacji',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Przykład: http://example.kanboard.net/ (Używane przez powiadomienia email)',
'Token regenerated.' => 'Token wygenerowany ponownie.',
'Date format' => 'Format daty',
'ISO format is always accepted, example: "%s" and "%s"' => 'Format ISO jest zawsze akceptowany, przykłady: "%s", "%s"',
'New private project' => 'Nowy projekt prywatny',
'This project is private' => 'Ten projekt jest prywatny',
- 'Type here to create a new sub-task' => 'Wpisz tutaj aby utworzyć pod-zadanie',
+ 'Type here to create a new sub-task' => 'Nazwa podzadania',
'Add' => 'Dodaj',
- 'Estimated time: %s hours' => 'Szacowany czas: %s godzin',
- 'Time spent: %s hours' => 'Przeznaczony czas: %s godzin',
- 'Started on %B %e, %Y' => 'Rozpoczęto %e %B %Y',
'Start date' => 'Data rozpoczęcia',
'Time estimated' => 'Szacowany czas',
'There is nothing assigned to you.' => 'Nie ma przypisanych zadań',
'My tasks' => 'Moje zadania',
- 'Activity stream' => 'Dziennik aktywności',
- 'Dashboard' => 'Panel',
+ 'Activity stream' => 'Strumień aktywności',
+ 'Dashboard' => 'Dashboard',
'Confirmation' => 'Potwierdzenie',
'Allow everybody to access to this project' => 'Udostępnij ten projekt wszystkim',
'Everybody have access to this project.' => 'Wszyscy mają dostęp do tego projektu.',
// 'Webhooks' => '',
// 'API' => '',
- // 'Github webhooks' => '',
- // 'Help on Github webhooks' => '',
'Create a comment from an external provider' => 'Utwórz komentarz od zewnętrznego dostawcy',
- // 'Github issue comment created' => '',
'Project management' => 'Menadżer projektu',
'My projects' => 'Moje projekty',
'Columns' => 'Kolumny',
- 'Task' => 'zadania',
+ 'Task' => 'Zadanie',
'Your are not member of any project.' => 'Nie bierzesz udziału w żadnym projekcie',
'Percentage' => 'Procent',
'Number of tasks' => 'Liczba zadań',
@@ -511,20 +461,19 @@ return array(
'Reportings' => 'Raporty',
'Task repartition for "%s"' => 'Przydział zadań dla "%s"',
'Analytics' => 'Analizy',
- 'Subtask' => 'Pod-zadanie',
- 'My subtasks' => 'Moje pod-zadania',
+ 'Subtask' => 'Podzadanie',
+ 'My subtasks' => 'Moje podzadania',
'User repartition' => 'Przydział użytkownika',
'User repartition for "%s"' => 'Przydział użytkownika dla "%s"',
'Clone this project' => 'Sklonuj ten projekt',
'Column removed successfully.' => 'Kolumna usunięta pomyślnie.',
- // 'Github Issue' => '',
'Not enough data to show the graph.' => 'Za mało danych do utworzenia wykresu.',
'Previous' => 'Poprzedni',
'The id must be an integer' => 'ID musi być liczbą całkowitą',
'The project id must be an integer' => 'ID projektu musi być liczbą całkowitą',
'The status must be an integer' => 'Status musi być liczbą całkowitą',
'The subtask id is required' => 'ID pod-zadanie jest wymagane',
- 'The subtask id must be an integer' => 'ID pod-zadania musi być liczbą całkowitą',
+ 'The subtask id must be an integer' => 'ID podzadania musi być liczbą całkowitą',
'The task id is required' => 'ID zadania jest wymagane',
'The task id must be an integer' => 'ID zadania musi być liczbą całkowitą',
'The user id must be an integer' => 'ID użytkownika musi być liczbą całkowitą',
@@ -533,9 +482,9 @@ return array(
'Unable to create this task.' => 'Nie można tworzyć zadania.',
'Cumulative flow diagram' => 'Zbiorowy diagram przepływu',
'Cumulative flow diagram for "%s"' => 'Zbiorowy diagram przepływu dla "%s"',
- 'Daily project summary' => 'Dzienne podsumowanie projektu',
+ 'Daily project summary' => 'Dzienne raport z projektu',
'Daily project summary export' => 'Eksport dziennego podsumowania projektu',
- 'Daily project summary export for "%s"' => 'Eksport dziennego podsumowania projektu dla "%s"',
+ 'Daily project summary export for "%s"' => 'Wygeneruj dzienny raport dla projektu: "%s"',
'Exports' => 'Eksporty',
'This export contains the number of tasks per column grouped per day.' => 'Ten eksport zawiera ilość zadań zgrupowanych w kolumnach na dzień',
'Nothing to preview...' => 'Nic do podejrzenia...',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Domyślny proces',
'Do you really want to remove this swimlane: "%s"?' => 'Czy na pewno chcesz usunąć proces: "%s"?',
'Inactive swimlanes' => 'Nieaktywne procesy',
- 'Set project manager' => 'Ustaw menadżera projektu',
- 'Set project member' => 'Ustaw członka projektu',
'Remove a swimlane' => 'Usuń proces',
- 'Rename' => 'Zmień nazwe',
'Show default swimlane' => 'Pokaż domyślny proces',
'Swimlane modification for the project "%s"' => 'Edycja procesów dla projektu "%s"',
'Swimlane not found.' => 'Nie znaleziono procesu.',
@@ -558,30 +504,19 @@ return array(
'Swimlanes' => 'Procesy',
'Swimlane updated successfully.' => 'Proces zaktualizowany pomyślnie.',
'The default swimlane have been updated successfully.' => 'Domyślny proces zaktualizowany pomyślnie.',
- 'Unable to create your swimlane.' => 'Nie można utworzyć procesu.',
'Unable to remove this swimlane.' => 'Nie można usunąć procesu.',
'Unable to update this swimlane.' => 'Nie można zaktualizować procesu.',
'Your swimlane have been created successfully.' => 'Proces tworzony pomyślnie.',
'Example: "Bug, Feature Request, Improvement"' => 'Przykład: "Błąd, Żądanie Funkcjonalności, Udoskonalenia"',
'Default categories for new projects (Comma-separated)' => 'Domyślne kategorie dla nowych projektów (oddzielone przecinkiem)',
- // 'Gitlab commit received' => '',
- // 'Gitlab issue opened' => '',
- // 'Gitlab issue closed' => '',
- // 'Gitlab webhooks' => '',
- // 'Help on Gitlab webhooks' => '',
'Integrations' => 'Integracje',
'Integration with third-party services' => 'Integracja z usługami firm trzecich',
- 'Role for this project' => 'Rola w tym projekcie',
- 'Project manager' => 'Manadżer projektu',
- 'Project member' => 'Członek projektu',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Menadżer projektu może zmieniać ustawienia projektu i posiada większe uprawnienia od zwykłego użytkownika',
- // 'Gitlab Issue' => '',
'Subtask Id' => 'ID pod-zadania',
- 'Subtasks' => 'Pod-zadania',
- 'Subtasks Export' => 'Eksport pod-zadań',
- 'Subtasks exportation for "%s"' => 'Eksporty pod-zadań dla "%s"',
- 'Task Title' => 'Tytuł zadania',
- 'Untitled' => 'Bez tytułu',
+ 'Subtasks' => 'Podzadania',
+ 'Subtasks Export' => 'Eksport podzadań',
+ 'Subtasks exportation for "%s"' => 'Wygeneruj raport podzadań dla projektu "%s"',
+ 'Task Title' => 'Nazwa zadania',
+ 'Untitled' => 'Bez nazwy',
'Application default' => 'Domyślne dla aplikacji',
'Language:' => 'Język:',
'Timezone:' => 'Strefa czasowa:',
@@ -602,10 +537,7 @@ return array(
'Time Tracking' => 'Śledzenie czasu',
'You already have one subtask in progress' => 'Masz już zadanie o statusie "w trakcie"',
'Which parts of the project do you want to duplicate?' => 'Które elementy projektu chcesz zduplikować?',
- // 'Disallow login form' => '',
- // 'Bitbucket commit received' => '',
- // 'Bitbucket webhooks' => '',
- // 'Help on Bitbucket webhooks' => '',
+ 'Disallow login form' => 'Zablokuj możliwość logowania',
'Start' => 'Początek',
'End' => 'Koniec',
'Task age in days' => 'Wiek zadania w dniach',
@@ -646,7 +578,6 @@ return array(
'This task' => 'To zadanie',
// '<1h' => '',
// '%dh' => '',
- '%b %e' => '%e %b',
'Expand tasks' => 'Rozwiń zadania',
'Collapse tasks' => 'Zwiń zadania',
'Expand/collapse tasks' => 'Zwiń/Rozwiń zadania',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Skróty klawiszowe',
'Open board switcher' => 'Przełącz tablice',
'Application' => 'Aplikacja',
- 'since %B %e, %Y at %k:%M %p' => 'od %e %B %Y o %k:%M',
'Compact view' => 'Widok kompaktowy',
'Horizontal scrolling' => 'Przewijanie poziome',
'Compact/wide view' => 'Pełny/Kompaktowy widok',
'No results match:' => 'Brak wyników:',
'Currency' => 'Waluta',
- 'Files' => 'Pliki',
- 'Images' => 'Obrazy',
'Private project' => 'Projekt prywatny',
'AUD - Australian Dollar' => 'AUD - Dolar australijski',
'CAD - Canadian Dollar' => 'CAD - Dolar kanadyjski',
@@ -684,8 +612,8 @@ return array(
'Transitions' => 'Przeniesienia',
'Executer' => 'Wykonał',
'Time spent in the column' => 'Czas spędzony w tej kolumnie',
- 'Task transitions' => 'Przeniesienia zadania',
- 'Task transitions export' => 'Export przeniesień zadania',
+ 'Task transitions' => 'Przeniesienia zadań',
+ 'Task transitions export' => 'Wygeneruj raport z przeniesień zadań',
'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Ten raport zawiera wszystkie przeniesienia pomiędzy kolumnami wraz z datą oraz osobą odpowiedzialną',
'Currency rates' => 'Kursy walut',
'Rate' => 'Kurs',
@@ -703,26 +631,20 @@ return array(
'The two factor authentication code is valid.' => 'Kod weryfikujący poprawny',
'Code' => 'Kod',
'Two factor authentication' => 'Uwierzytelnianie dwustopniowe',
- 'Enable/disable two factor authentication' => 'Włącz/Wyłącz uwierzytelnianie dwustopniowe',
'This QR code contains the key URI: ' => 'Ten kod QR zawiera URI klucza: ',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Zapisz sekretny klucz w swoim oprogramowaniu TOTP (na przykład FreeOTP lub Google Authenticator)',
'Check my code' => 'Sprawdź kod',
'Secret key: ' => 'Tajny kod: ',
'Test your device' => 'Przetestuj urządzenie',
'Assign a color when the task is moved to a specific column' => 'Przypisz kolor gdy zadanie jest przeniesione do danej kolumny',
'%s via Kanboard' => '%s poprzez Kanboard',
- 'uploaded by: %s' => 'Dodane przez: %s',
- 'uploaded on: %s' => 'Data dodania: %s',
- 'size: %s' => 'Rozmiar: %s',
- // 'Burndown chart for "%s"' => '',
- // 'Burndown chart' => '',
+ 'Burndown chart for "%s"' => 'Wykres Burndown dla "%s"',
+ 'Burndown chart' => 'Wykres Burndown',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
'Screenshot taken %s' => 'Zrzut ekranu zapisany %s',
- 'Add a screenshot' => 'Dodaj zrzut ekranu',
+ 'Add a screenshot' => 'Dołącz zrzut ekranu',
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Zrób zrzut ekranu i wciśnij CTRL+V by dodać go tutaj.',
'Screenshot uploaded successfully.' => 'Zrzut ekranu dodany.',
'SEK - Swedish Krona' => 'SEK - Korona szwedzka',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Identyfikator projektu to opcjonalny kod alfanumeryczny do identyfikacji projektu.',
'Identifier' => 'Identyfikator',
'Disable two factor authentication' => 'Wyłącz uwierzytelnianie dwuetapowe',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Czy na pewno chcesz wyłączyć uwierzytelnianie dwuetapowe dla tego użytkownika: "%s"?',
@@ -731,7 +653,6 @@ return array(
'A task cannot be linked to itself' => 'Link do zadania nie może wskazywać na samego siebie',
'The exact same link already exists' => 'Taki link już istnieje',
'Recurrent task is scheduled to be generated' => 'Zaplanowano zadanie cykliczne',
- 'Recurring information' => 'Informacje o zadaniu cyklicznym',
'Score' => 'Wynik',
'The identifier must be unique' => 'Identyfikator musi być unikatowy',
'This linked task id doesn\'t exists' => 'Id zadania nie istnieje',
@@ -761,7 +682,7 @@ return array(
'Calendar settings' => 'Ustawienia kalendarza',
'Project calendar view' => 'Widok kalendarza projektu',
'Project settings' => 'Ustawienia Projektu',
- 'Show subtasks based on the time tracking' => 'Pokaż pod-zadania w śledzeniu czasu',
+ 'Show subtasks based on the time tracking' => 'Pokaż podzadania w śledzeniu czasu',
'Show tasks based on the creation date' => 'Pokaż zadania względem daty utworzenia',
'Show tasks based on the start date' => 'Pokaż zadania względem daty rozpoczęcia',
'Subtasks time tracking' => 'Śledzenie czasu pod-zadań',
@@ -773,34 +694,23 @@ return array(
'Two factor authentication disabled' => 'Uwierzytelnianie dwuetapowe wyłączone',
'Two factor authentication enabled' => 'Uwierzytelnianie dwuetapowe włączone',
'Unable to update this user.' => 'Nie można zaktualizować tego użytkownika',
- 'There is no user management for private projects.' => 'Dla projektów prywatnych nie występuje zarządzanie użytkownikami.',
+ 'There is no user management for private projects.' => 'Projekty prywatne nie wspierają zarządzania użytkownikami. Projekt prywatny ma tylko jednego użytkownika.',
// 'User that will receive the email' => '',
// 'Email subject' => '',
- // 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
+ 'Date' => 'Data',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
- // 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
- // 'Column change' => '',
- // 'Position change' => '',
- // 'Swimlane change' => '',
- // 'Assignee change' => '',
+ 'Send a task by email to someone' => 'Wyślij zadanie mailem do kogokolwiek',
+ 'Reopen a task' => 'Otwórz ponownie zadanie',
+ 'Column change' => 'Zmiana kolumny',
+ 'Position change' => 'Zmiana pozycji',
+ 'Swimlane change' => 'Zmiana Swimlane',
+ 'Assignee change' => 'Zmiana przypisanego użytkownika',
// '[%s] Overdue tasks' => '',
- // 'Notification' => '',
+ 'Notification' => 'Powiadomienie',
// '%s moved the task #%d to the first swimlane' => '',
// '%s moved the task #%d to the swimlane "%s"' => '',
- // 'Swimlane' => '',
+ 'Swimlane' => 'Proces',
// 'Gravatar' => '',
// '%s moved the task %s to the first swimlane' => '',
// '%s moved the task %s to the swimlane "%s"' => '',
@@ -811,12 +721,12 @@ return array(
// 'The task have been moved to the first swimlane' => '',
// 'The task have been moved to another swimlane:' => '',
// 'Overdue tasks for the project "%s"' => '',
- // 'New title: %s' => '',
- // 'The task is not assigned anymore' => '',
- // 'New assignee: %s' => '',
- // 'There is no category now' => '',
- // 'New category: %s' => '',
- // 'New color: %s' => '',
+ 'New title: %s' => 'Nowy tytuł: %s',
+ 'The task is not assigned anymore' => 'Brak osoby odpowiedzialnej za zadanie',
+ 'New assignee: %s' => 'Nowy odpowiedzialny: %s',
+ 'There is no category now' => 'Aktualnie zadanie nie posiada kategorii',
+ 'New category: %s' => 'Nowa kategoria: %s',
+ 'New color: %s' => 'Nowy kolor: %s',
// 'New complexity: %d' => '',
// 'The due date have been removed' => '',
// 'There is no description anymore' => '',
@@ -826,61 +736,54 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
- // 'I want to receive notifications for:' => '',
- // 'All tasks' => '',
- // 'Only for tasks assigned to me' => '',
- // 'Only for tasks created by me' => '',
- // 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
+ 'I want to receive notifications for:' => 'Wysyłaj powiadomienia dla:',
+ 'All tasks' => 'Wszystkich zadań',
+ 'Only for tasks assigned to me' => 'Tylko zadań przypisanych do mnie',
+ 'Only for tasks created by me' => 'Tylko zadań utworzonych przeze mnie',
+ 'Only for tasks created by me and assigned to me' => 'Tylko zadań przypisanych lub utworzonych przeze mnie',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
// '<15m' => '',
// '<30m' => '',
- // 'Stop timer' => '',
- // 'Start timer' => '',
- // 'Add project member' => '',
- // 'Enable notifications' => '',
- // 'My activity stream' => '',
- // 'My calendar' => '',
- // 'Search tasks' => '',
- // 'Back to the calendar' => '',
- // 'Filters' => '',
- // 'Reset filters' => '',
- // 'My tasks due tomorrow' => '',
- // 'Tasks due today' => '',
- // 'Tasks due tomorrow' => '',
- // 'Tasks due yesterday' => '',
- // 'Closed tasks' => '',
- // 'Open tasks' => '',
- // 'Not assigned' => '',
- // 'View advanced search syntax' => '',
- // 'Overview' => '',
- // '%b %e %Y' => '',
- // 'Board/Calendar/List view' => '',
- // 'Switch to the board view' => '',
- // 'Switch to the calendar view' => '',
- // 'Switch to the list view' => '',
- // 'Go to the search/filter box' => '',
- // 'There is no activity yet.' => '',
- // 'No tasks found.' => '',
- // 'Keyboard shortcut: "%s"' => '',
- // 'List' => '',
- // 'Filter' => '',
- // 'Advanced search' => '',
- // 'Example of query: ' => '',
- // 'Search by project: ' => '',
- // 'Search by column: ' => '',
- // 'Search by assignee: ' => '',
- // 'Search by color: ' => '',
- // 'Search by category: ' => '',
- // 'Search by description: ' => '',
- // 'Search by due date: ' => '',
+ 'Stop timer' => 'Zatrzymaj pomiar czasu',
+ 'Start timer' => 'Uruchom pomiar czasu',
+ 'Add project member' => 'Dodaj członka projektu',
+ 'Enable notifications' => 'Włącz powiadomienia',
+ 'My activity stream' => 'Moja aktywność',
+ 'My calendar' => 'Mój kalendarz',
+ 'Search tasks' => 'Szukaj zadań',
+ 'Back to the calendar' => 'Wróć do kalendarza',
+ 'Filters' => 'Filtry',
+ 'Reset filters' => 'Resetuj zastosowane filtry',
+ 'My tasks due tomorrow' => 'Moje zadania do jutra',
+ 'Tasks due today' => 'Zadania do dzisiaj',
+ 'Tasks due tomorrow' => 'Zadania do jutra',
+ 'Tasks due yesterday' => 'Zadania na wczoraj',
+ 'Closed tasks' => 'Zamknięte zadania',
+ 'Open tasks' => 'Otwarte zadania',
+ 'Not assigned' => 'Nieprzypisane zadania',
+ 'View advanced search syntax' => 'Pomoc dotycząca budowania filtrów',
+ 'Overview' => 'Podsumowanie',
+ 'Board/Calendar/List view' => 'Widok: Tablica/Kalendarz/Lista',
+ 'Switch to the board view' => 'Przełącz na tablicę',
+ 'Switch to the calendar view' => 'Przełącz na kalendarz',
+ 'Switch to the list view' => 'Przełącz na listę',
+ 'Go to the search/filter box' => 'Użyj pola wyszukiwania/filtrów',
+ 'There is no activity yet.' => 'Brak powiadomień',
+ 'No tasks found.' => 'Nie znaleziono zadań',
+ 'Keyboard shortcut: "%s"' => 'Skrót klawiaturowy: "%s"',
+ 'List' => 'Lista',
+ 'Filter' => 'Filtr',
+ 'Advanced search' => 'Zaawansowane wyszukiwanie',
+ 'Example of query: ' => 'Przykładowe zapytanie:',
+ 'Search by project: ' => 'Szukaj wg projektów:',
+ 'Search by column: ' => 'Szukaj wg kolumn:',
+ 'Search by assignee: ' => 'Szukaj wg użytkownika:',
+ 'Search by color: ' => 'Szukaj wg koloru:',
+ 'Search by category: ' => 'Szukaj wg kategorii:',
+ 'Search by description: ' => 'Szukaj wg opisu:',
+ 'Search by due date: ' => 'Szukaj wg terminu:',
// 'Lead and Cycle time for "%s"' => '',
// 'Average time spent into each column for "%s"' => '',
// 'Average time spent into each column' => '',
@@ -894,104 +797,81 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
- // 'Time spent into each column' => '',
+ 'Time spent into each column' => 'Czas spędzony przez zadanie w każdej z kolumn',
// 'The lead time is the duration between the task creation and the completion.' => '',
// 'The cycle time is the duration between the start date and the completion.' => '',
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
- // 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
+ 'Edit Authentication' => 'Edycja autoryzacji',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
- // 'New remote user' => '',
- // 'New local user' => '',
- // 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
- // 'This feature does not work with all browsers.' => '',
+ 'New remote user' => 'Nowy użytkownik zdalny',
+ 'New local user' => 'Nowy użytkownik lokalny',
+ 'Default task color' => 'Domyślny kolor zadań',
+ 'This feature does not work with all browsers.' => 'Ta funkcja może nie działać z każdą przeglądarką',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
// 'Include closed tasks in the cumulative flow diagram' => '',
- // 'Current swimlane: %s' => '',
- // 'Current column: %s' => '',
- // 'Current category: %s' => '',
- // 'no category' => '',
+ 'Current swimlane: %s' => 'Bieżący swimlane: %s',
+ 'Current column: %s' => 'Bieżąca kolumna: %s',
+ 'Current category: %s' => 'Bieżąca kategoria: %s',
+ 'no category' => 'brak kategorii',
// 'Current assignee: %s' => '',
- // 'not assigned' => '',
- // 'Author:' => '',
- // 'contributors' => '',
- // 'License:' => '',
- // 'License' => '',
- // 'Project Administrator' => '',
- // 'Enter the text below' => '',
- // 'Gantt chart for %s' => '',
- // 'Sort by position' => '',
- // 'Sort by date' => '',
- // 'Add task' => '',
- // 'Start date:' => '',
- // 'Due date:' => '',
- // 'There is no start date or due date for this task.' => '',
+ 'not assigned' => 'Brak osoby odpowiedzialnej',
+ 'Author:' => 'Autor',
+ 'contributors' => 'współautorzy',
+ 'License:' => 'Licencja:',
+ 'License' => 'Licencja',
+ 'Enter the text below' => 'Wpisz tekst poniżej',
+ 'Gantt chart for %s' => 'Wykres Gantt dla %s',
+ 'Sort by position' => 'Sortuj wg pozycji',
+ 'Sort by date' => 'Sortuj wg daty',
+ 'Add task' => 'Dodaj zadanie',
+ 'Start date:' => 'Data rozpoczęcia:',
+ 'Due date:' => 'Termin',
+ 'There is no start date or due date for this task.' => 'Brak daty rozpoczęcia lub terminu zadania',
// 'Moving or resizing a task will change the start and due date of the task.' => '',
// 'There is no task in your project.' => '',
- // 'Gantt chart' => '',
- // 'People who are project managers' => '',
- // 'People who are project members' => '',
+ 'Gantt chart' => 'Wykres Gantta',
+ 'People who are project managers' => 'Użytkownicy będący menedżerami projektu',
+ 'People who are project members' => 'Użytkownicy będący uczestnikami projektu',
// 'NOK - Norwegian Krone' => '',
- // 'Show this column' => '',
- // 'Hide this column' => '',
- // 'open file' => '',
- // 'End date' => '',
- // 'Users overview' => '',
- // 'Managers' => '',
- // 'Members' => '',
- // 'Shared project' => '',
- // 'Project managers' => '',
- // 'Project members' => '',
- // 'Gantt chart for all projects' => '',
- // 'Projects list' => '',
- // 'Gantt chart for this project' => '',
- // 'Project board' => '',
- // 'End date:' => '',
- // 'There is no start date or end date for this project.' => '',
- // 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
- // 'Link type' => '',
- // 'Change task color when using a specific task link' => '',
- // 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
- // 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
+ 'Show this column' => 'Pokaż tą kolumnę',
+ 'Hide this column' => 'Ukryj tą kolumnę',
+ 'open file' => 'otwórz plik',
+ 'End date' => 'Data zakończenia',
+ 'Users overview' => 'Przegląd użytkowników',
+ 'Members' => 'Uczestnicy',
+ 'Shared project' => 'Projekt udostępniony',
+ 'Project managers' => 'Menedżerowie projektu',
+ 'Gantt chart for all projects' => 'Wykres Gantta dla wszystkich projektów',
+ 'Projects list' => 'Lista projektów',
+ 'Gantt chart for this project' => 'Wykres Gantta dla bieżacego projektu',
+ 'Project board' => 'Talica projektu',
+ 'End date:' => 'Data zakończenia:',
+ 'There is no start date or end date for this project.' => 'Nie zdefiniowano czasu trwania projektu',
+ 'Projects Gantt chart' => 'Wykres Gantta dla projektów',
+ 'Link type' => 'Rodzaj link\'u',
+ 'Change task color when using a specific task link' => 'Zmień kolor zadania używając specjalnego adresu URL',
+ 'Task link creation or modification' => 'Adres URL do utworzenia zadania lub modyfikacji',
+ 'Milestone' => 'Kamień milowy',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
- // 'Documentation' => '',
+ 'Documentation' => 'Dokumentacja',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
- // 'Author' => '',
- // 'Version' => '',
- // 'Plugins' => '',
- // 'There is no plugin loaded.' => '',
- // 'Set maximum column height' => '',
- // 'Remove maximum column height' => '',
- // 'My notifications' => '',
- // 'Custom filters' => '',
+ 'Author' => 'Autor',
+ 'Version' => 'Wersja',
+ 'Plugins' => 'Wtyczki',
+ 'There is no plugin loaded.' => 'Nie wykryto żadnych wtyczek.',
+ 'Set maximum column height' => 'Rozwiń kolumny',
+ 'Remove maximum column height' => 'Zwiń kolumny',
+ 'My notifications' => 'Powiadomienia',
+ 'Custom filters' => 'Dostosuj filtry',
// 'Your custom filter have been created successfully.' => '',
// 'Unable to create your custom filter.' => '',
// 'Custom filter removed successfully.' => '',
@@ -1000,68 +880,272 @@ return array(
// 'Your custom filter have been updated successfully.' => '',
// 'Unable to update custom filter.' => '',
// 'Web' => '',
- // 'New attachment on task #%d: %s' => '',
- // 'New comment on task #%d' => '',
- // 'Comment updated on task #%d' => '',
- // 'New subtask on task #%d' => '',
- // 'Subtask updated on task #%d' => '',
- // 'New task #%d: %s' => '',
- // 'Task updated #%d' => '',
- // 'Task #%d closed' => '',
- // 'Task #%d opened' => '',
- // 'Column changed for task #%d' => '',
- // 'New position for task #%d' => '',
- // 'Swimlane changed for task #%d' => '',
- // 'Assignee changed on task #%d' => '',
+ 'New attachment on task #%d: %s' => 'Nowy załącznik do zadania #%d: %s',
+ 'New comment on task #%d' => 'Nowy komentarz do zadania #%d',
+ 'Comment updated on task #%d' => 'Aktualizacja komentarza do zadania #%d',
+ 'New subtask on task #%d' => 'Nowe podzadanie dla zadania #%d',
+ 'Subtask updated on task #%d' => 'Aktualizacja podzadania w zadaniu #%d',
+ 'New task #%d: %s' => 'Nowe zadanie #%d: %s',
+ 'Task updated #%d' => 'Aktualizacja zadania #%d',
+ 'Task #%d closed' => 'Zamknięto zadanie #%d',
+ 'Task #%d opened' => 'Otwarto zadanie #%d',
+ 'Column changed for task #%d' => 'Zmieniono kolumnę zadania #%d',
+ 'New position for task #%d' => 'Ustalono nową pozycję zadania #%d',
+ 'Swimlane changed for task #%d' => 'Zmieniono swimlane dla zadania #%d',
+ 'Assignee changed on task #%d' => 'Zmieniono osobę odpowiedzialną dla zadania #%d',
// '%d overdue tasks' => '',
// 'Task #%d is overdue' => '',
- // 'No new notifications.' => '',
- // 'Mark all as read' => '',
- // 'Mark as read' => '',
+ 'No new notifications.' => 'Brak nowych powiadomień.',
+ 'Mark all as read' => 'Oznacz wszystkie jako przeczytane',
+ 'Mark as read' => 'Oznacz jako przeczytane',
// 'Total number of tasks in this column across all swimlanes' => '',
- // 'Collapse swimlane' => '',
- // 'Expand swimlane' => '',
- // 'Add a new filter' => '',
- // 'Share with all project members' => '',
+ 'Collapse swimlane' => 'Zwiń swimlane',
+ 'Expand swimlane' => 'Rozwiń swimlane',
+ 'Add a new filter' => 'Dodaj nowy filtr',
+ 'Share with all project members' => 'Udostępnij wszystkim uczestnikom projektu',
// 'Shared' => '',
- // 'Owner' => '',
- // 'Unread notifications' => '',
- // 'My filters' => '',
- // 'Notification methods:' => '',
+ 'Owner' => 'Właściciel',
+ 'Unread notifications' => 'Nieprzeczytane powiadomienia',
+ 'My filters' => 'Moje filtry',
+ 'Notification methods:' => 'Metody powiadomień:',
// 'Import tasks from CSV file' => '',
// 'Unable to read your file' => '',
// '%d task(s) have been imported successfully.' => '',
// 'Nothing have been imported!' => '',
// 'Import users from CSV file' => '',
// '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
+ 'Comma' => 'Przecinek',
+ 'Semi-colon' => 'Średnik',
+ 'Tab' => 'Tabulacja',
+ 'Vertical bar' => 'Kreska pionowa',
+ 'Double Quote' => 'Cudzysłów',
+ 'Single Quote' => 'Apostrof',
// '%s attached a file to the task #%d' => '',
// 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
+ 'Append filter (instead of replacement)' => 'Dołączaj filtr do zastosowanego filtru(zamiast przełączać)',
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
+ 'change sorting' => 'odwróć sortowanie',
+ 'Tasks Importation' => 'Import zadań',
+ 'Delimiter' => 'Separator pola',
+ 'Enclosure' => 'Separator tekstu',
+ 'CSV File' => 'Plik CSV',
+ 'Instructions' => 'Instrukcje',
+ 'Your file must use the predefined CSV format' => 'Twój plik musi być zgodny z predefiniowanym formatem CSV (pobierz szablon)',
+ 'Your file must be encoded in UTF-8' => 'Twój plik musi być kodowany w UTF-8',
+ 'The first row must be the header' => 'Pierwszy wiersz pliku musi definiować nagłówki',
+ 'Duplicates are not verified for you' => 'Duplikaty nie będą weryfikowane',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'Data musi być w formacie ISO: YYYY-MM-DD',
+ 'Download CSV template' => 'Pobierz szablon pliku CSV',
// 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
+ 'Duplicates are not imported' => 'Duplikaty nie zostaną zaimportowane',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ 'Groups' => 'Grupy',
+ 'Members of %s' => 'Członkowie %s',
+ 'New group' => 'Nowa grupa',
+ 'Group created successfully.' => 'Grupa została utworzona.',
+ 'Unable to create your group.' => 'Nie można utworzyć grupy.',
+ 'Edit group' => 'Edytuj grupę',
+ 'Group updated successfully.' => 'Grupa została zaaktualizowana.',
+ 'Unable to update your group.' => 'Nie można zaaktualizować grupy.',
+ 'Add group member to "%s"' => 'Dodaj członka do grupy "%s"',
+ 'Group member added successfully.' => 'Użytkownik został dodany do grupy.',
+ 'Unable to add group member.' => 'Nie można dodać użytkownika do grupy.',
+ 'Remove user from group "%s"' => 'Usuń użytkownika z grupy "%s"',
+ 'User removed successfully from this group.' => 'Użytkownik został usunięty z grupy.',
+ 'Unable to remove this user from the group.' => 'Nie można usunąć użytkownika z grupy.',
+ 'Remove group' => 'Usuń grupę',
+ 'Group removed successfully.' => 'Grupa została usunięta.',
+ 'Unable to remove this group.' => 'Nie można usunąć grupy.',
+ 'Project Permissions' => 'Prawa dostępowe projektu',
+ 'Manager' => 'Menedżer',
+ 'Project Manager' => 'Menedżer projektu',
+ 'Project Member' => 'Uczestnik projektu',
+ 'Project Viewer' => 'Obserwator projektu',
+ 'Your account is locked for %d minutes' => 'Twoje konto zostało zablokowane na %d minut',
+ 'Invalid captcha' => 'Błędny kod z obrazka (captcha)',
+ 'The name must be unique' => 'Nazwa musi być unikatowa',
+ 'View all groups' => 'Wyświetl wszystkie grupy',
+ 'View group members' => 'Wyświetl wszystkich członków grupy',
+ 'There is no user available.' => 'Żaden użytkownik nie jest dostępny.',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Czy napewno chcesz usunąć użytkownika "%s" z grupy "%s"?',
+ 'There is no group.' => 'Nie utworzono jeszcze żadnej grupy.',
+ 'External Id' => 'Zewnętrzny Id',
+ 'Add group member' => 'Dodaj członka grupy',
+ 'Do you really want to remove this group: "%s"?' => 'Czy napewno chcesz usunąć grupę "%s"?',
+ 'There is no user in this group.' => 'Wybrana grupa nie posiada członków.',
+ 'Remove this user' => 'Usuń użytkownika',
+ 'Permissions' => 'Prawa dostępu',
+ 'Allowed Users' => 'Użytkownicy, którzy mają dostęp',
+ 'No user have been allowed specifically.' => 'Żaden użytkownik nie ma przyznanego dostępu.',
+ 'Role' => 'Rola',
+ 'Enter user name...' => 'Wprowadź nazwę użytkownika...',
+ 'Allowed Groups' => 'Grupy, które mają dostęp',
+ 'No group have been allowed specifically.' => 'Żadna grupa nie ma przyznanego dostępu.',
+ 'Group' => 'Grupa',
+ 'Group Name' => 'Nazwa grupy',
+ 'Enter group name...' => 'Wprowadź nazwę grupy...',
+ 'Role:' => 'Rola:',
+ 'Project members' => 'Uczestnicy projektu',
+ // 'Compare hours for "%s"' => '',
+ '%s mentioned you in the task #%d' => '%s wspomiał o Tobie w zadaniu #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s wspomiał o Tobie w komentarzu do zadania #%d',
+ 'You were mentioned in the task #%d' => 'Wspomiano o Tobie w zadaniu #%d',
+ 'You were mentioned in a comment on the task #%d' => 'Wspomiano o Tobie w komentarzu do zadania #%d',
+ 'Mentioned' => 'Wspomiano',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ 'There is no integration registered at the moment.' => 'W chwili obecnej funkcjonalność ta została wyłączona.',
+ 'Password Reset for Kanboard' => 'Resetuj hasło do Kanboarda',
+ 'Forgot password?' => 'Nie pamiętasz hasła?',
+ 'Enable "Forget Password"' => 'Włącz możliwość odzyskiwania zapomianego hasła przez użytkowników',
+ 'Password Reset' => 'Resetuj hasło',
+ 'New password' => 'Nowe hasło',
+ 'Change Password' => 'Zmień hasło',
+ 'To reset your password click on this link:' => 'Kliknij w poniższy link, aby zresetować hasło:',
+ 'Last Password Reset' => 'Ostatnie odzyskiwanie hasła',
+ 'The password has never been reinitialized.' => 'Hasło nigdy nie było odzyskiwane.',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ 'Password reset history' => 'Historia hasła',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ 'Close all tasks of this column' => 'Zamknij wszystkie zadania w tej kolumnie',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Wtyczki obsługujące dodatkowe powiadomienia nie zostały zainstalowane. Dalej jednak możesz korzystać z standardowych powiadomień (sprawdź w ustawieniach Twojego profilu).',
+ 'My dashboard' => 'Mój dashboard',
+ 'My profile' => 'Mój profil',
+ 'Project owner:' => 'Właściciel:',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'Identyfikator projektu jest opcjonalny i musi być alfanumeryczny, przykład: MYPROJECT.',
+ 'Project owner' => 'Właściciel projektu',
+ 'Those dates are useful for the project Gantt chart.' => 'Daty te są przydatne dla wykresu Gantta.',
+ 'Private projects do not have users and groups management.' => 'Projekty prywatne nie wspierają obsługi użytkowników i grup.',
+ // 'There is no project member.' => '',
+ 'Priority' => 'Priorytet',
+ 'Task priority' => 'Priorytety zadań',
+ 'General' => 'Ogólne',
+ 'Dates' => 'Czas życia projektu',
+ 'Default priority' => 'Domyślny priorytet',
+ 'Lowest priority' => 'Najniższy priorytet',
+ 'Highest priority' => 'Najwyższy priorytet',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'Jeżeli dla najniższego i najwyższego priorytetu ustawisz 0, to priorytety dla tablicy zostaną wyłączone.',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ 'Auto' => 'Automatyczny',
+ 'Related' => 'Powiązanie',
+ 'Attachment' => 'Załącznik',
+ // 'Title not found' => '',
+ 'Web Link' => 'Link URL',
+ 'External links' => 'Linki zewnętrzne',
+ 'Add external link' => 'Dodaj link zewnętrzny',
+ 'Type' => 'Typ',
+ 'Dependency' => 'Zależność',
+ 'Add internal link' => 'Dodaj link do innego zadania',
+ 'Add a new external link' => 'Dodaj nowy link zewnętrzny',
+ 'Edit external link' => 'Edytuj link zewnętrzny',
+ 'External link' => 'Link zewnętrzny',
+ 'Copy and paste your link here...' => 'Skopiuj i wklej link tutaj ...',
+ // 'URL' => '',
+ 'There is no external link for the moment.' => 'Brak linków zewnętrznych.',
+ 'Internal links' => 'Linki do innych zadań',
+ 'There is no internal link for the moment.' => 'Brak powiązań do innych zadań.',
+ // 'Assign to me' => '',
+ 'Me' => 'JA',
+ 'Do not duplicate anything' => 'Nie kopiuj żadnego projektu',
+ 'Projects management' => 'Zarządzanie projektami',
+ 'Users management' => 'Zarządzanie użytkownikami',
+ 'Groups management' => 'Zarządzanie grupami',
+ 'Create from another project' => 'Utwórz na podstawie innego projektu',
+ 'There is no subtask at the moment.' => 'Brak podzadań.',
+ 'open' => 'otwarty',
+ 'closed' => 'zamknięty',
+ 'Priority:' => 'Priorytet:',
+ // 'Reference:' => '',
+ 'Complexity:' => 'Złożoność:',
+ 'Swimlane:' => 'Proces:',
+ 'Column:' => 'Kolumna:',
+ 'Position:' => 'Pozycja:',
+ 'Creator:' => 'Utworzył:',
+ 'Time estimated:' => 'Szacowany czas:',
+ '%s hours' => '%s godzin',
+ 'Time spent:' => 'Wykorzystany czas:',
+ 'Created:' => 'Utworzone:',
+ 'Modified:' => 'Zmodyfikowane:',
+ 'Completed:' => 'Ukończone:',
+ 'Started:' => 'Rozpoczęte:',
+ 'Moved:' => 'Przesunięcie:',
+ 'Task #%d' => 'Zadanie #%d',
+ 'Sub-tasks' => 'Podzadania',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ 'Drag and drop your files here' => 'Przeciągnij i upuść pliki tutaj',
+ 'choose files' => 'wybierz pliki',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ 'Download' => 'Pobierz',
+ 'Uploaded: %s' => 'Data załączenia: %s',
+ 'Size: %s' => 'Rozmiar: %s',
+ 'Uploaded by %s' => 'Załadowany przez %s',
+ 'Filename' => 'Nazwa pliku',
+ 'Size' => 'Rozmiar',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ 'Upload a file' => 'Prześlij plik',
+ 'There is no attachment at the moment.' => 'Brak załączników.',
+ 'View file' => 'Wyświetl plik',
+ 'Last activity' => 'Ostatnia aktywność',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php
index 6ee5f2dd..8f2ebedb 100644
--- a/app/Locale/pt_BR/translations.php
+++ b/app/Locale/pt_BR/translations.php
@@ -68,11 +68,10 @@ return array(
'Disable' => 'Desativar',
'Enable' => 'Ativar',
'New project' => 'Novo projeto',
- 'Do you really want to remove this project: "%s"?' => 'Você realmente deseja remover este projeto: "%s" ?',
+ 'Do you really want to remove this project: "%s"?' => 'Você realmente deseja remover este projeto: "%s"?',
'Remove project' => 'Remover projeto',
'Edit the board for "%s"' => 'Editar o board para "%s"',
'All projects' => 'Todos os projetos',
- 'Change columns' => 'Modificar colunas',
'Add a new column' => 'Adicionar uma nova coluna',
'Title' => 'Título',
'Nobody assigned' => 'Ninguém designado',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Abrir uma tarefa',
'Do you really want to open this task: "%s"?' => 'Você realmente deseja abrir esta tarefa: "%s"?',
'Back to the board' => 'Voltar ao board',
- 'Created on %B %e, %Y at %k:%M %p' => 'Criado em %d %B %Y às %H:%M',
'There is nobody assigned' => 'Não há ninguém designado',
'Column on the board:' => 'Coluna no board:',
- 'Status is open' => 'Status está aberto',
- 'Status is closed' => 'Status está finalizado',
'Close this task' => 'Finalizar esta tarefa',
'Open this task' => 'Abrir esta tarefa',
'There is no description.' => 'Não há descrição.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'O ID é obrigatório',
'The project id is required' => 'O ID do projeto é obrigatório',
'The project name is required' => 'O nome do projeto é obrigatório',
- 'This project must be unique' => 'Este projeto deve ser único',
'The title is required' => 'O título é obrigatório',
'Settings saved successfully.' => 'Configurações salvas com sucesso.',
'Unable to save your settings.' => 'Não é possível salvar suas configurações.',
@@ -159,29 +154,19 @@ return array(
'Work in progress' => 'Em andamento',
'Done' => 'Finalizado',
'Application version:' => 'Versão da aplicação:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Finalizado em %d %B %Y às %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d %B %Y às %H:%M',
- 'Date created' => 'Data de criação',
- 'Date completed' => 'Data da finalização',
'Id' => 'Id',
'%d closed tasks' => '%d tarefas finalizadas',
'No task for this project' => 'Não há tarefa para este projeto',
'Public link' => 'Link público',
- 'Change assignee' => 'Mudar a designação',
- 'Change assignee for the task "%s"' => 'Modificar designação para a tarefa "%s"',
+ 'Change assignee' => 'Alterar designação',
+ 'Change assignee for the task "%s"' => 'Alterar designação para a tarefa "%s"',
'Timezone' => 'Fuso horário',
'Sorry, I didn\'t find this information in my database!' => 'Desculpe, não encontrei esta informação no meu banco de dados!',
'Page not found' => 'Página não encontrada',
'Complexity' => 'Complexidade',
'Task limit' => 'Limite de tarefas',
'Task count' => 'Número de tarefas',
- 'Edit project access list' => 'Editar lista de acesso ao projeto',
- 'Allow this user' => 'Permitir este usuário',
- 'Don\'t forget that administrators have access to everything.' => 'Não esqueça que administradores têm acesso a tudo.',
- 'Revoke' => 'Revogar',
- 'List of authorized users' => 'Lista de usuários autorizados',
'User' => 'Usuário',
- 'Nobody have access to this project.' => 'Ninguém tem acesso a este projeto.',
'Comments' => 'Comentários',
'Write your text in Markdown' => 'Escreva seu texto em Markdown',
'Leave a comment' => 'Deixe um comentário',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Editar esta tarefa',
'Due Date' => 'Data de vencimento',
'Invalid date' => 'Data inválida',
- 'Must be done before %B %e, %Y' => 'Deve ser finalizado antes de %d %B %Y',
- '%B %e, %Y' => '%d %B %Y',
- '%b %e, %Y' => '%d %B %Y',
'Automatic actions' => 'Ações automáticas',
'Your automatic action have been created successfully.' => 'Sua ação automética foi criada com sucesso.',
'Unable to create your automatic action.' => 'Não é possível criar sua ação automática.',
@@ -217,7 +199,7 @@ return array(
'Remove an automatic action' => 'Remover uma ação automática',
'Assign the task to a specific user' => 'Designar a tarefa para um usuário específico',
'Assign the task to the person who does the action' => 'Designar a tarefa para a pessoa que executa a ação',
- 'Duplicate the task to another project' => 'Duplicar a tarefa para um outro projeto',
+ 'Duplicate the task to another project' => 'Duplicar a tarefa para outro projeto',
'Move a task to another column' => 'Mover a tarefa para outra coluna',
'Task modification' => 'Modificação de tarefa',
'Task creation' => 'Criação de tarefa',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Designar uma cor para um usuário específico',
'Column title' => 'Título da coluna',
'Position' => 'Posição',
- 'Move Up' => 'Mover para cima',
- 'Move Down' => 'Mover para baixo',
'Duplicate to another project' => 'Duplicar para outro projeto',
'Duplicate' => 'Duplicar',
'link' => 'link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Comentário removido com sucesso.',
'Unable to remove this comment.' => 'Não é possível remover este comentário.',
'Do you really want to remove this comment?' => 'Você realmente deseja remover este comentário?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Somente os administradores ou o criator deste comentário possuem acesso a esta página.',
'Current password for the user "%s"' => 'Senha atual para o usuário "%s"',
'The current password is required' => 'A senha atual é obrigatória',
'Wrong password' => 'Senha incorreta',
@@ -267,14 +246,10 @@ return array(
'External authentication failed' => 'Autenticação externa falhou',
'Your external account is linked to your profile successfully.' => 'Sua conta externa está agora ligada ao seu perfil.',
'Email' => 'E-mail',
- 'Link my Google Account' => 'Vincular minha Conta do Google',
- 'Unlink my Google Account' => 'Desvincular minha Conta do Google',
- 'Login with my Google Account' => 'Entrar com minha Conta do Google',
- 'Project not found.' => 'Projeto não encontrado.',
'Task removed successfully.' => 'Tarefa removida com sucesso.',
'Unable to remove this task.' => 'Não foi possível remover esta tarefa.',
'Remove a task' => 'Remover uma tarefa',
- 'Do you really want to remove this task: "%s"?' => 'Você realmente deseja remover esta tarefa: "%s"',
+ 'Do you really want to remove this task: "%s"?' => 'Você realmente deseja remover esta tarefa: "%s"?',
'Assign automatically a color based on a category' => 'Atribuir automaticamente uma cor com base em uma categoria',
'Assign automatically a category based on a color' => 'Atribuir automaticamente uma categoria com base em uma cor',
'Task creation or modification' => 'Criação ou modificação de tarefa',
@@ -287,12 +262,12 @@ return array(
'Your category have been updated successfully.' => 'A sua categoria foi atualizada com sucesso.',
'Unable to update your category.' => 'Não foi possível atualizar a sua categoria.',
'Remove a category' => 'Remover uma categoria',
- 'Category removed successfully.' => 'Categoria removido com sucesso.',
+ 'Category removed successfully.' => 'Categoria removida com sucesso.',
'Unable to remove this category.' => 'Não foi possível remover esta categoria.',
'Category modification for the project "%s"' => 'Modificação de categoria para o projeto "%s"',
- 'Category Name' => 'Nome da Categoria',
+ 'Category Name' => 'Nome da categoria',
'Add a new category' => 'Adicionar uma nova categoria',
- 'Do you really want to remove this category: "%s"?' => 'Você realmente deseja remover esta categoria: "%s"',
+ 'Do you really want to remove this category: "%s"?' => 'Você realmente deseja remover esta categoria: "%s"?',
'All categories' => 'Todas as categorias',
'No category' => 'Nenhum categoria',
'The name is required' => 'O nome é obrigatório',
@@ -300,7 +275,7 @@ return array(
'Unable to remove this file.' => 'Não foi possível remover este arquivo.',
'File removed successfully.' => 'Arquivo removido com sucesso.',
'Attach a document' => 'Anexar um documento',
- 'Do you really want to remove this file: "%s"?' => 'Você realmente deseja remover este arquivo: "%s"',
+ 'Do you really want to remove this file: "%s"?' => 'Você realmente deseja remover este arquivo: "%s"?',
'Attachments' => 'Anexos',
'Edit the task' => 'Editar a tarefa',
'Edit the description' => 'Editar a descrição',
@@ -331,14 +306,10 @@ return array(
'Unable to update your sub-task.' => 'Não foi possível atualizar a sua subtarefa.',
'Unable to create your sub-task.' => 'Não é possível criar a sua subtarefa.',
'Sub-task added successfully.' => 'Subtarefa adicionada com sucesso.',
- 'Maximum size: ' => 'Tamanho máximo:',
+ 'Maximum size: ' => 'Tamanho máximo: ',
'Unable to upload the file.' => 'Não foi possível carregar o arquivo.',
'Display another project' => 'Exibir outro projeto',
- 'Login with my Github Account' => 'Entrar com minha Conta do Github',
- 'Link my Github Account' => 'Associar à minha Conta do Github',
- 'Unlink my Github Account' => 'Desassociar a minha Conta do Github',
'Created by %s' => 'Criado por %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Última modificação em %B %e, %Y às %k: %M %p',
'Tasks Export' => 'Exportar Tarefas',
'Tasks exportation for "%s"' => 'As tarefas foram exportadas para "%s"',
'Start Date' => 'Data inicial',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Quero receber notificações apenas destes projetos:',
'view the task on Kanboard' => 'ver a tarefa no Kanboard',
'Public access' => 'Acesso público',
- 'User management' => 'Gerenciamento de usuários',
'Active tasks' => 'Tarefas ativas',
'Disable public access' => 'Desabilitar o acesso público',
'Enable public access' => 'Habilitar o acesso público',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'E-mail:',
'Notifications:' => 'Notificações:',
'Notifications' => 'Notificações',
- 'Group:' => 'Grupo:',
- 'Regular user' => 'Usuário comum',
'Account type:' => 'Tipo de conta:',
'Edit profile' => 'Editar perfil',
'Change password' => 'Alterar senha',
'Password modification' => 'Alteração de senha',
'External authentications' => 'Autenticação externa',
- 'Google Account' => 'Conta do Google',
- 'Github Account' => 'Conta do Github',
'Never connected.' => 'Nunca conectado.',
- 'No account linked.' => 'Nenhuma conta associada.',
- 'Account linked.' => 'Conta associada.',
'No external authentication enabled.' => 'Nenhuma autenticação externa habilitada.',
'Password modified successfully.' => 'Senha alterada com sucesso.',
'Unable to change the password.' => 'Não foi possível alterar a senha.',
@@ -446,22 +410,15 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s mudou a designação da tarefa %s para %s',
'New password for the user "%s"' => 'Nova senha para o usuário "%s"',
'Choose an event' => 'Escolher um evento',
- 'Github commit received' => 'Github commit received',
- 'Github issue opened' => 'Github issue opened',
- 'Github issue closed' => 'Github issue closed',
- 'Github issue reopened' => 'Github issue reopened',
- 'Github issue assignee change' => 'Github issue assignee change',
- 'Github issue label change' => 'Github issue label change',
'Create a task from an external provider' => 'Criar uma tarefa por meio de um serviço externo',
'Change the assignee based on an external username' => 'Alterar designação com base em um usuário externo',
'Change the category based on an external label' => 'Alterar categoria com base em um rótulo externo',
'Reference' => 'Referência',
- 'Reference: %s' => 'Referência: %s',
'Label' => 'Rótulo',
'Database' => 'Banco de dados',
'About' => 'Sobre',
'Database driver:' => 'Driver do banco de dados:',
- 'Board settings' => 'Configurações do Board',
+ 'Board settings' => 'Configurações do board',
'URL and token' => 'URL e token',
'Webhook settings' => 'Configurações do Webhook',
'URL for task creation:' => 'URL para a criação da tarefa:',
@@ -474,17 +431,13 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frequência em segundos (60 segundos por padrão)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frequência em segundos (0 para desativar este recurso, 10 segundos por padrão)',
'Application URL' => 'URL da Aplicação',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Exemplo: http://example.kanboard.net/ (utilizado nas notificações por e-mail)',
- 'Token regenerated.' => 'Token ',
+ 'Token regenerated.' => 'Novo token gerado.',
'Date format' => 'Formato de data',
'ISO format is always accepted, example: "%s" and "%s"' => 'O formato ISO é sempre aceito, exemplo: "%s" e "%s"',
'New private project' => 'Novo projeto privado',
'This project is private' => 'Este projeto é privado',
'Type here to create a new sub-task' => 'Digite aqui para criar uma nova subtarefa',
'Add' => 'Adicionar',
- 'Estimated time: %s hours' => 'Tempo estimado: %s horas',
- 'Time spent: %s hours' => 'Tempo gasto: %s horas',
- 'Started on %B %e, %Y' => 'Iniciado em %B %e, %Y',
'Start date' => 'Data de início',
'Time estimated' => 'Tempo estimado',
'There is nothing assigned to you.' => 'Não há nada designado à você.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Todos possuem acesso a este projeto.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github webhooks',
- 'Help on Github webhooks' => 'Ajuda sobre os webhooks do GitHub',
'Create a comment from an external provider' => 'Criar um comentário por meio de um serviço externo',
- 'Github issue comment created' => 'Github issue comment created',
'Project management' => 'Gerenciamento de projetos',
'My projects' => 'Meus projetos',
'Columns' => 'Colunas',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Redistribuição de usuário para "%s"',
'Clone this project' => 'Clonar este projeto',
'Column removed successfully.' => 'Coluna removida com sucesso.',
- 'Github Issue' => 'Github Issue',
'Not enough data to show the graph.' => 'Não há dados suficientes para mostrar o gráfico.',
'Previous' => 'Anterior',
'The id must be an integer' => 'O ID deve ser um número inteiro',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Swimlane padrão',
'Do you really want to remove this swimlane: "%s"?' => 'Você realmente deseja remover esta swimlane: "%s"?',
'Inactive swimlanes' => 'Desativar swimlanes',
- 'Set project manager' => 'Definir gerente do projeto',
- 'Set project member' => 'Definir membro do projeto',
'Remove a swimlane' => 'Remover uma swimlane',
- 'Rename' => 'Renomear',
'Show default swimlane' => 'Exibir swimlane padrão',
'Swimlane modification for the project "%s"' => 'Modificação de swimlane para o projeto "%s"',
'Swimlane not found.' => 'Swimlane não encontrada.',
@@ -558,37 +504,26 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane atualizada com sucesso.',
'The default swimlane have been updated successfully.' => 'A swimlane padrão foi atualizada com sucesso.',
- 'Unable to create your swimlane.' => 'Não foi possível criar a sua swimlane.',
'Unable to remove this swimlane.' => 'Não foi possível remover esta swimlane.',
'Unable to update this swimlane.' => 'Não foi possível atualizar esta swimlane.',
'Your swimlane have been created successfully.' => 'Sua swimlane foi criada com sucesso.',
- 'Example: "Bug, Feature Request, Improvement"' => 'Exemplo: "Bug, Feature Request, Improvement"',
+ 'Example: "Bug, Feature Request, Improvement"' => 'Exemplo: "Bug, Solicitação de Recurso, Melhoria"',
'Default categories for new projects (Comma-separated)' => 'Categorias padrões para novos projetos (separadas por vírgula)',
- 'Gitlab commit received' => 'Gitlab commit received',
- 'Gitlab issue opened' => 'Gitlab issue opened',
- 'Gitlab issue closed' => 'Gitlab issue closed',
- 'Gitlab webhooks' => 'Gitlab webhooks',
- 'Help on Gitlab webhooks' => 'Ajuda sobre os webhooks do GitLab',
'Integrations' => 'Integrações',
'Integration with third-party services' => 'Integração com serviços de terceiros',
- 'Role for this project' => 'Função para este projeto',
- 'Project manager' => 'Gerente do projeto',
- 'Project member' => 'Membro do projeto',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Um gerente do projeto pode alterar as configurações do projeto e ter mais privilégios que um usuário padrão.',
- 'Gitlab Issue' => 'Gitlab Issue',
'Subtask Id' => 'ID da subtarefa',
'Subtasks' => 'Subtarefas',
'Subtasks Export' => 'Exportar subtarefas',
'Subtasks exportation for "%s"' => 'Subtarefas exportadas para "%s"',
'Task Title' => 'Título da Tarefa',
'Untitled' => 'Sem título',
- 'Application default' => 'Aplicação padrão',
+ 'Application default' => 'Padrão da aplicação',
'Language:' => 'Idioma',
'Timezone:' => 'Fuso horário',
'All columns' => 'Todas as colunas',
'Calendar' => 'Calendário',
'Next' => 'Próximo',
- // '#%d' => '',
+ '#%d' => '#%d',
'All swimlanes' => 'Todas as swimlanes',
'All colors' => 'Todas as cores',
'Moved to column %s' => 'Mover para a coluna %s',
@@ -598,23 +533,20 @@ return array(
'Edit column "%s"' => 'Editar a coluna "%s"',
'Select the new status of the subtask: "%s"' => 'Selecionar um novo status para a subtarefa: "%s"',
'Subtask timesheet' => 'Gestão de tempo das subtarefas',
- 'There is nothing to show.' => 'Não há nada para mostrar',
+ 'There is nothing to show.' => 'Não há nada para mostrar.',
'Time Tracking' => 'Gestão de tempo',
'You already have one subtask in progress' => 'Você já tem um subtarefa em andamento',
'Which parts of the project do you want to duplicate?' => 'Quais partes do projeto você deseja duplicar?',
'Disallow login form' => 'Proibir o formulário de login',
- 'Bitbucket commit received' => '"Commit" recebido via Bitbucket',
- 'Bitbucket webhooks' => 'Webhook Bitbucket',
- 'Help on Bitbucket webhooks' => 'Ajuda sobre os webhooks do Bitbucket',
'Start' => 'Início',
'End' => 'Fim',
'Task age in days' => 'Idade da tarefa em dias',
'Days in this column' => 'Dias nesta coluna',
- // '%dd' => '',
+ '%dd' => '%dd',
'Add a link' => 'Adicionar uma associação',
'Add a new link' => 'Adicionar uma nova associação',
'Do you really want to remove this link: "%s"?' => 'Você realmente deseja remover esta associação: "%s"?',
- 'Do you really want to remove this link with task #%d?' => 'Você realmente deseja remover esta associação com a tarefa n°%d?',
+ 'Do you really want to remove this link with task #%d?' => 'Você realmente deseja remover esta associação com a tarefa #%d?',
'Field required' => 'Campo requerido',
'Link added successfully.' => 'Associação criada com sucesso.',
'Link updated successfully.' => 'Associação atualizada com sucesso.',
@@ -637,8 +569,8 @@ return array(
'is blocked by' => 'está bloqueada por',
'duplicates' => 'duplica',
'is duplicated by' => 'é duplicada por',
- 'is a child of' => 'é um filho de',
- 'is a parent of' => 'é um parente do',
+ 'is a child of' => 'é filha de',
+ 'is a parent of' => 'é mãe de',
'targets milestone' => 'visa um milestone',
'is a milestone of' => 'é um milestone de',
'fixes' => 'corrige',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Esta tarefa',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%e %b',
'Expand tasks' => 'Expandir tarefas',
'Collapse tasks' => 'Contrair tarefas',
'Expand/collapse tasks' => 'Expandir/Contrair tarefas',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Atalhos de teclado',
'Open board switcher' => 'Abrir o comutador de painel',
'Application' => 'Aplicação',
- 'since %B %e, %Y at %k:%M %p' => 'desde o %d/%m/%Y às %H:%M',
'Compact view' => 'Vista reduzida',
'Horizontal scrolling' => 'Rolagem horizontal',
'Compact/wide view' => 'Alternar entre a vista compacta e ampliada',
'No results match:' => 'Nenhum resultado:',
'Currency' => 'Moeda',
- 'Files' => 'Arquivos',
- 'Images' => 'Imagens',
'Private project' => 'Projeto privado',
'AUD - Australian Dollar' => 'AUD - Dólar australiano',
'CAD - Canadian Dollar' => 'CAD - Dólar canadense',
@@ -698,40 +626,33 @@ return array(
'%s remove the assignee of the task %s' => '%s removeu a pessoa designada para a tarefa %s',
'Enable Gravatar images' => 'Ativar imagens do Gravatar',
'Information' => 'Informações',
- 'Check two factor authentication code' => 'Verificação do código de autenticação à fator duplo',
- 'The two factor authentication code is not valid.' => 'O código de autenticação à fator duplo não é válido',
- 'The two factor authentication code is valid.' => 'O código de autenticação à fator duplo é válido',
+ 'Check two factor authentication code' => 'Verifique o código de autenticação em duas etapas',
+ 'The two factor authentication code is not valid.' => 'O código de autenticação em duas etapas não é válido.',
+ 'The two factor authentication code is valid.' => 'O código de autenticação em duas etapas é válido.',
'Code' => 'Código',
- 'Two factor authentication' => 'Autenticação à fator duplo',
- 'Enable/disable two factor authentication' => 'Ativar/Desativar autenticação à fator duplo',
+ 'Two factor authentication' => 'Autenticação em duas etapas',
'This QR code contains the key URI: ' => 'Este Código QR contém a chave URI:',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Salve esta chave secreta no seu software TOTP (por exemplo Google Authenticator ou FreeOTP).',
'Check my code' => 'Verifique o meu código',
'Secret key: ' => 'Chave secreta:',
'Test your device' => 'Teste o seu dispositivo',
'Assign a color when the task is moved to a specific column' => 'Atribuir uma cor quando a tarefa é movida em uma coluna específica',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'carregado por: %s',
- 'uploaded on: %s' => 'carregado em: %s',
- 'size: %s' => 'tamanho: %s',
'Burndown chart for "%s"' => 'Gráfico de Burndown para "%s"',
'Burndown chart' => 'Gráfico de Burndown',
'This chart show the task complexity over the time (Work Remaining).' => 'Este gráfico mostra a complexidade da tarefa ao longo do tempo (Trabalho Restante).',
- 'Screenshot taken %s' => 'Screenshot tomada em %s',
- 'Add a screenshot' => 'Adicionar uma Screenshot',
- 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Tomar um screenshot e pressione CTRL + V ou ⌘ + V para colar aqui.',
- 'Screenshot uploaded successfully.' => 'Screenshot enviada com sucesso.',
+ 'Screenshot taken %s' => 'Captura de tela tirada em %s',
+ 'Add a screenshot' => 'Adicionar uma captura de tela',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Tire uma captura de tela e pressione CTRL + V ou ⌘ + V para colar aqui.',
+ 'Screenshot uploaded successfully.' => 'Captura de tela enviada com sucesso.',
'SEK - Swedish Krona' => 'SEK - Coroa sueca',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'O identificador de projeto é um código alfanumérico opcional utilizado para identificar o seu projeto.',
'Identifier' => 'Identificador',
- 'Disable two factor authentication' => 'Desativar autenticação à dois fatores',
- 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Você deseja realmente desativar a autenticação à dois fatores para esse usuário: "%s"?',
+ 'Disable two factor authentication' => 'Desativar autenticação em duas etapas',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Você realmente deseja desativar a autenticação em duas etapas para este usuário: "%s"?',
'Edit link' => 'Editar um link',
'Start to type task title...' => 'Digite o título do trabalho...',
- 'A task cannot be linked to itself' => 'Uma tarefa não pode ser ligada a si própria',
- 'The exact same link already exists' => 'Um link idêntico jà existe',
+ 'A task cannot be linked to itself' => 'Uma tarefa não pode ser vinculada a si própria',
+ 'The exact same link already exists' => 'Um link idêntico já existe',
'Recurrent task is scheduled to be generated' => 'A tarefa recorrente está programada para ser criada',
- 'Recurring information' => 'Informação sobre a recorrência',
'Score' => 'Complexidade',
'The identifier must be unique' => 'O identificador deve ser único',
'This linked task id doesn\'t exists' => 'O identificador da tarefa associada não existe',
@@ -739,7 +660,7 @@ return array(
'Edit recurrence' => 'Modificar a recorrência',
'Generate recurrent task' => 'Gerar uma tarefa recorrente',
'Trigger to generate recurrent task' => 'Trigger para gerar tarefa recorrente',
- 'Factor to calculate new due date' => 'Fator para o cálculo do nova data limite',
+ 'Factor to calculate new due date' => 'Fator para o cálculo da nova data limite',
'Timeframe to calculate new due date' => 'Escala de tempo para o cálculo da nova data limite',
'Base date to calculate new due date' => 'Data a ser utilizada para calcular a nova data limite',
'Action date' => 'Data da ação',
@@ -770,32 +691,21 @@ return array(
'iCal feed' => 'Subscrição iCal',
'Preferences' => 'Preferências',
'Security' => 'Segurança',
- 'Two factor authentication disabled' => 'Autenticação à fator duplo desativado',
- 'Two factor authentication enabled' => 'Autenticação à fator duplo activado',
+ 'Two factor authentication disabled' => 'Autenticação em duas etapas desativada',
+ 'Two factor authentication enabled' => 'Autenticação em duas etapas ativada',
'Unable to update this user.' => 'Impossível de atualizar esse usuário.',
'There is no user management for private projects.' => 'Não há gerenciamento de usuários para projetos privados.',
'User that will receive the email' => 'O usuário que vai receber o e-mail',
'Email subject' => 'Assunto do e-mail',
'Date' => 'Data',
- 'By @%s on Bitbucket' => 'Por @%s no Bitbucket',
- 'Bitbucket Issue' => 'Bitbucket Issue',
- 'Commit made by @%s on Bitbucket' => 'Commit feito por @%s no Bitbucket',
- 'Commit made by @%s on Github' => 'Commit feito por @%s no Github',
- 'By @%s on Github' => 'Por @%s no Github',
- 'Commit made by @%s on Gitlab' => 'Commit feito por @%s no Gitlab',
'Add a comment log when moving the task between columns' => 'Adicionar um comentário de log quando uma tarefa é movida para uma outra coluna',
'Move the task to another column when the category is changed' => 'Mover uma tarefa para outra coluna quando a categoria mudou',
'Send a task by email to someone' => 'Enviar uma tarefa por e-mail a alguém',
'Reopen a task' => 'Reabrir uma tarefa',
- 'Bitbucket issue opened' => 'Bitbucket issue opened',
- 'Bitbucket issue closed' => 'Bitbucket issue closed',
- 'Bitbucket issue reopened' => 'Bitbucket issue reopened',
- 'Bitbucket issue assignee change' => 'Bitbucket issue assignee change',
- 'Bitbucket issue comment created' => 'Bitbucket issue comment created',
'Column change' => 'Mudança de coluna',
'Position change' => 'Mudança de posição',
'Swimlane change' => 'Mudança de swimlane',
- 'Assignee change' => 'Mudança do designado',
+ 'Assignee change' => 'Mudança de designação',
'[%s] Overdue tasks' => '[%s] Tarefas atrasadas',
'Notification' => 'Notificação',
'%s moved the task #%d to the first swimlane' => '%s moveu a tarefa #%d para a primeira swimlane',
@@ -804,7 +714,7 @@ return array(
'Gravatar' => 'Gravatar',
'%s moved the task %s to the first swimlane' => '%s moveu a tarefa %s para a primeira swimlane',
'%s moved the task %s to the swimlane "%s"' => '%s moveu a tarefa %s para a swimlane "%s"',
- 'This report contains all subtasks information for the given date range.' => 'Este relatório contém informações de todas as sub-tarefas para o período selecionado.',
+ 'This report contains all subtasks information for the given date range.' => 'Este relatório contém informações de todas as subtarefas para o período selecionado.',
'This report contains all tasks information for the given date range.' => 'Este relatório contém informações de todas as tarefas para o período selecionado.',
'Project activities for %s' => 'Atividade do projeto "%s"',
'view the board on Kanboard' => 'ver o painel no Kanboard',
@@ -825,18 +735,12 @@ return array(
'Time estimated changed: %sh' => 'O tempo estimado foi mudado/ %sh',
'The field "%s" have been updated' => 'O campo "%s" foi atualizada',
'The description have been modified' => 'A descrição foi modificada',
- 'Do you really want to close the task "%s" as well as all subtasks?' => 'Você realmente quer fechar a tarefa "%s" e todas as suas sub-tarefas?',
- 'Swimlane: %s' => 'Swimlane: %s',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'Você realmente deseja finalizar a tarefa "%s" e todas as suas subtarefas?',
'I want to receive notifications for:' => 'Eu quero receber as notificações para:',
'All tasks' => 'Todas as tarefas',
'Only for tasks assigned to me' => 'Somente as tarefas atribuídas a mim',
'Only for tasks created by me' => 'Apenas as tarefas que eu criei',
'Only for tasks created by me and assigned to me' => 'Apenas as tarefas que eu criei e aquelas atribuídas a mim',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%d/%m/%Y %H:%M',
- 'New due date: %B %e, %Y' => 'Nova data limite: %d/%m/%Y',
- 'Start date changed: %B %e, %Y' => 'Data de início alterada: %d/%m/%Y',
- '%k:%M %p' => '%H:%M',
'%%Y-%%m-%%d' => '%%d/%%m/%%Y',
'Total for all columns' => 'Total para todas as colunas',
'You need at least 2 days of data to show the chart.' => 'Você precisa de pelo menos 2 dias de dados para visualizar o gráfico.',
@@ -844,7 +748,7 @@ return array(
'<30m' => '<30m',
'Stop timer' => 'Stop timer',
'Start timer' => 'Start timer',
- 'Add project member' => 'Adicionar um membro ao projeto',
+ 'Add project member' => 'Adicionar membro ao projeto',
'Enable notifications' => 'Ativar as notificações',
'My activity stream' => 'Meu feed de atividades',
'My calendar' => 'Minha agenda',
@@ -861,14 +765,13 @@ return array(
'Not assigned' => 'Não designada',
'View advanced search syntax' => 'Ver a sintaxe para pesquisa avançada',
'Overview' => 'Visão global',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Vista Painel/Calendário/Lista',
'Switch to the board view' => 'Mudar para o modo Painel',
'Switch to the calendar view' => 'Mudar par o modo Calendário',
'Switch to the list view' => 'Mudar par o modo Lista',
'Go to the search/filter box' => 'Ir para o campo de pesquisa',
'There is no activity yet.' => 'Não há nenhuma atividade ainda.',
- 'No tasks found.' => 'Nenhuma tarefa encontrada',
+ 'No tasks found.' => 'Nenhuma tarefa encontrada.',
'Keyboard shortcut: "%s"' => 'Tecla de atalho: "%s"',
'List' => 'Lista',
'Filter' => 'Filtro',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Este gráfico mostra o tempo médio do Lead and cycle time das últimas %d tarefas.',
'Average time into each column' => 'Tempo médio de cada coluna',
'Lead and cycle time' => 'Lead and cycle time',
- 'Google Authentication' => 'Autenticação Google',
- 'Help on Google authentication' => 'Ajuda com a autenticação Google',
- 'Github Authentication' => 'Autenticação Github',
- 'Help on Github authentication' => 'Ajuda com a autenticação Github',
'Lead time: ' => 'Lead time: ',
'Cycle time: ' => 'Cycle time: ',
'Time spent into each column' => 'Tempo gasto em cada coluna',
@@ -906,19 +805,13 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Se a tarefa não está fechada, a hora atual é usada no lugar da data de conclusão.',
'Set automatically the start date' => 'Definir automaticamente a data de início',
'Edit Authentication' => 'Modificar a autenticação',
- 'Google Id' => 'Google ID',
- 'Github Id' => 'Github Id',
'Remote user' => 'Usuário remoto',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Os usuários remotos não conservam as suas senhas no banco de dados Kanboard, exemplos: contas LDAP, Github ou Google.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Se você marcar "Interdir o formulário de autenticação", os identificadores entrados no formulário de login serão ignorado.',
- 'By @%s on Gitlab' => 'Por @%s no Gitlab',
- 'Gitlab issue comment created' => 'Comentário criado em um bilhete Gitlab',
'New remote user' => 'Criar um usuário remoto',
'New local user' => 'Criar um usuário local',
'Default task color' => 'Cor padrão para as tarefas',
- 'Hide sidebar' => 'Esconder a barra lateral',
- 'Expand sidebar' => 'Expandir a barra lateral',
- 'This feature does not work with all browsers.' => 'Esta funcionalidade não é compatível com todos os navegadores',
+ 'This feature does not work with all browsers.' => 'Esta funcionalidade não é compatível com todos os navegadores.',
'There is no destination project available.' => 'Não há nenhum projeto de destino disponível.',
'Trigger automatically subtask time tracking' => 'Ativar automaticamente o monitoramento do tempo para as subtarefas',
'Include closed tasks in the cumulative flow diagram' => 'Incluir as tarefas fechadas no diagrama de fluxo acumulado',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'contribuidores',
'License:' => 'Licença:',
'License' => 'Licença',
- 'Project Administrator' => 'Administrador de Projeto',
'Enter the text below' => 'Entre o texto abaixo',
'Gantt chart for %s' => 'Gráfico de Gantt para %s',
'Sort by position' => 'Ordenar por posição',
@@ -952,38 +844,26 @@ return array(
'open file' => 'abrir um arquivo',
'End date' => 'Data de término',
'Users overview' => 'Visão geral dos usuários',
- 'Managers' => 'Gerentes',
'Members' => 'Membros',
'Shared project' => 'Projeto compartilhado',
'Project managers' => 'Gerentes de projeto',
- 'Project members' => 'Membros de projeto',
'Gantt chart for all projects' => 'Gráfico de Gantt para todos os projetos',
'Projects list' => 'Lista dos projetos',
- 'Gantt chart for this project' => 'Gráfico de Gantt para este projecto',
+ 'Gantt chart for this project' => 'Gráfico de Gantt para este projeto',
'Project board' => 'Painel do projeto',
'End date:' => 'Data de término:',
'There is no start date or end date for this project.' => 'Não há data de início ou data de término para este projeto.',
'Projects Gantt chart' => 'Gráfico de Gantt dos projetos',
- 'Start date: %s' => 'Data de início: %s',
- 'End date: %s' => 'Data de término: %s',
'Link type' => 'Tipo de link',
'Change task color when using a specific task link' => 'Mudar a cor da tarefa quando um link específico é utilizado',
'Task link creation or modification' => 'Criação ou modificação de um link em uma tarefa',
- 'Login with my Gitlab Account' => 'Login com a minha conta Gitlab',
'Milestone' => 'Milestone',
- 'Gitlab Authentication' => 'Autenticação Gitlab',
- 'Help on Gitlab authentication' => 'Ajuda com a autenticação Gitlab',
- 'Gitlab Id' => 'Gitlab Id',
- 'Gitlab Account' => 'Conta Gitlab',
- 'Link my Gitlab Account' => 'Vincular minha conta Gitlab',
- 'Unlink my Gitlab Account' => 'Desvincular minha conta Gitlab',
'Documentation: %s' => 'Documentação: %s',
'Switch to the Gantt chart view' => 'Mudar para a vista gráfico de Gantt',
'Reset the search/filter box' => 'Reiniciar o campo de pesquisa',
'Documentation' => 'Documentação',
'Table of contents' => 'Índice',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Ajuda com as permissões de projetos',
'Author' => 'Autor',
'Version' => 'Versão',
'Plugins' => 'Extensões',
@@ -1028,40 +908,244 @@ return array(
'Unread notifications' => 'Notificações não lidas',
'My filters' => 'Meus filtros',
'Notification methods:' => 'Métodos de notificação:',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'Import tasks from CSV file' => 'Importar tarefas a partir de arquivo CSV',
+ 'Unable to read your file' => 'Não foi possível ler seu arquivo',
+ '%d task(s) have been imported successfully.' => '%d tarefa(s) importada(s) com sucesso.',
+ 'Nothing have been imported!' => 'Nada foi importado!',
+ 'Import users from CSV file' => 'Importar usuários a partir de arquivo CSV',
+ '%d user(s) have been imported successfully.' => '%d usuário(s) importado(s) com sucesso.',
+ 'Comma' => 'Vírgula',
+ 'Semi-colon' => 'Ponto e vírgula',
+ 'Tab' => 'Tab',
+ 'Vertical bar' => 'Barra vertical',
+ 'Double Quote' => 'Aspas duplas',
+ 'Single Quote' => 'Aspas simples',
+ '%s attached a file to the task #%d' => '%s anexou um arquivo à tarefa #%d',
+ 'There is no column or swimlane activated in your project!' => 'Não há coluna ou swimlane ativa em seu projeto!',
+ 'Append filter (instead of replacement)' => 'Adicionar filtro (em vez de substituir)',
+ 'Append/Replace' => 'Adicionar/Substituir',
+ 'Append' => 'Adicionar',
+ 'Replace' => 'Substituir',
+ 'Import' => 'Importar',
+ 'change sorting' => 'alterar ordenação',
+ 'Tasks Importation' => 'Importação de Tarefas',
+ 'Delimiter' => 'Separador',
+ 'Enclosure' => 'Delimitador de campos',
+ 'CSV File' => 'Arquivo CSV',
+ 'Instructions' => 'Instruções',
+ 'Your file must use the predefined CSV format' => 'Seu arquivo deve utilizar o formato CSV pré-definido',
+ 'Your file must be encoded in UTF-8' => 'Seu arquivo deve estar codificado em UTF-8',
+ 'The first row must be the header' => 'A primeira linha deve ser o cabeçalho',
+ 'Duplicates are not verified for you' => 'Registros duplicados não são verificados',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'A data de vencimento deve utilizar o formato ISO: YYYY-MM-DD',
+ 'Download CSV template' => 'Baixar modelo de arquivo CSV',
+ 'No external integration registered.' => 'Nenhuma integração externa registrada.',
+ 'Duplicates are not imported' => 'Registros duplicados não são importados',
+ 'Usernames must be lowercase and unique' => 'Nomes de usuário devem ser únicos e em letras minúsculas',
+ 'Passwords will be encrypted if present' => 'Senhas serão encriptadas, se presentes',
+ '%s attached a new file to the task %s' => '%s anexou um novo arquivo a tarefa %s',
+ 'Assign automatically a category based on a link' => 'Atribuir automaticamente uma categoria baseada num link',
+ 'BAM - Konvertible Mark' => 'BAM - Mark conversível',
+ 'Assignee Username' => 'Usuário designado',
+ 'Assignee Name' => 'Nome do designado',
+ 'Groups' => 'Grupos',
+ 'Members of %s' => 'Membros do %s',
+ 'New group' => 'Novo grupo',
+ 'Group created successfully.' => 'Grupo criado com sucesso.',
+ 'Unable to create your group.' => 'Não foi possível de criar o seu grupo.',
+ 'Edit group' => 'Editar o grupo',
+ 'Group updated successfully.' => 'Grupo atualizado com sucesso.',
+ 'Unable to update your group.' => 'Não foi possível atualizar o seu grupo.',
+ 'Add group member to "%s"' => 'Adicionar um membro ao grupo "%s"',
+ 'Group member added successfully.' => 'Membro adicionado com sucesso ao o grupo.',
+ 'Unable to add group member.' => 'Não foi possivel adicionar esse membro ao grupo.',
+ 'Remove user from group "%s"' => 'Remover usuário do grupo "%s"',
+ 'User removed successfully from this group.' => 'Usuário removido com sucesso deste grupo.',
+ 'Unable to remove this user from the group.' => 'Não foi possivel remover este Usuário do grupo.',
+ 'Remove group' => 'Remover o grupo',
+ 'Group removed successfully.' => 'Grupo removido com sucesso.',
+ 'Unable to remove this group.' => 'Não foi possível remover este grupo.',
+ 'Project Permissions' => 'Permissões do projeto',
+ 'Manager' => 'Gerente',
+ 'Project Manager' => 'Gerente de projeto',
+ 'Project Member' => 'Membro de projeto',
+ // 'Project Viewer' => '',
+ 'Your account is locked for %d minutes' => 'A sua conta está bloqueada por %d minutos',
+ 'Invalid captcha' => 'Captcha inválido',
+ 'The name must be unique' => 'O nome deve ser único',
+ 'View all groups' => 'Ver todos os grupos',
+ 'View group members' => 'Ver os membros do grupo',
+ 'There is no user available.' => 'Não há nenhum usuário disponível',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Você realmente deseja remover o usuário "%s" do grupo "%s"?',
+ 'There is no group.' => 'Não há nenhum grupo.',
+ 'External Id' => 'Id externo',
+ 'Add group member' => 'Adicionar um membro ao grupo',
+ 'Do you really want to remove this group: "%s"?' => 'Você realmente deseja excluir este grupo: "%s"?',
+ 'There is no user in this group.' => 'Não há usuários neste grupo.',
+ 'Remove this user' => 'Remover este usuário',
+ 'Permissions' => 'Permissões',
+ 'Allowed Users' => 'Usuários autorizados',
+ 'No user have been allowed specifically.' => 'Nenhum usuário foi especificamente autorizado.',
+ 'Role' => 'Função',
+ 'Enter user name...' => 'Digite o nome do usuário...',
+ 'Allowed Groups' => 'Grupos autorizados',
+ 'No group have been allowed specifically.' => 'Nenhum grupo foi especificamente autorizado.',
+ 'Group' => 'Grupo',
+ 'Group Name' => 'Nome do grupo',
+ 'Enter group name...' => 'Digite o nome do grupo',
+ 'Role:' => 'Função:',
+ 'Project members' => 'Membros de projeto',
+ 'Compare hours for "%s"' => 'Comparar as horas para "%s"',
+ '%s mentioned you in the task #%d' => '%s mencionou você na tarefa #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s mencionou você num comentário da tarefa #%d',
+ 'You were mentioned in the task #%d' => 'Você foi mencionado na tarefa #%d',
+ 'You were mentioned in a comment on the task #%d' => 'Você foi mencionado num comentário da tarefa #%d',
+ 'Mentioned' => 'Mencionado',
+ 'Compare Estimated Time vs Actual Time' => 'Comparar o tempo estimado e o tempo atual',
+ 'Estimated hours: ' => 'Horas estimadas: ',
+ 'Actual hours: ' => 'Horas reais: ',
+ 'Hours Spent' => 'Horas gastas',
+ 'Hours Estimated' => 'Horas estimadas',
+ 'Estimated Time' => 'Tempo estimado',
+ 'Actual Time' => 'Tempo real',
+ 'Estimated vs actual time' => 'Tempo estimado vs tempo real',
+ 'RUB - Russian Ruble' => 'RUB - Rublo russo',
+ 'Assign the task to the person who does the action when the column is changed' => 'Atribuir a tarefa a pessoa que faz a ação quando a coluna é alterada',
+ 'Close a task in a specific column' => 'Fechar uma tarefa em uma coluna específica',
+ 'Time-based One-time Password Algorithm' => 'Senha de uso único baseado no tempo',
+ 'Two-Factor Provider: ' => 'Provedor de autenticação a dois fatores: ',
+ 'Disable two-factor authentication' => 'Desativar a autenticação a dois fatores',
+ 'Enable two-factor authentication' => 'Ativar a autenticação a dois fatores',
+ 'There is no integration registered at the moment.' => 'Não há nenhuma integração registrada por enquanto.',
+ 'Password Reset for Kanboard' => 'Redefinir a senha para Kanboard',
+ 'Forgot password?' => 'Esqueceu a senha?',
+ 'Enable "Forget Password"' => 'Activar a funcionalidade "Esqueceu a senha"',
+ 'Password Reset' => 'Redefinir a senha',
+ 'New password' => 'Nova senha',
+ 'Change Password' => 'Alterar a senha',
+ 'To reset your password click on this link:' => 'Para redefinir a sua senha, clique neste link:',
+ 'Last Password Reset' => 'Última redefinição de senha',
+ 'The password has never been reinitialized.' => 'A senha nunca foi redifinida.',
+ 'Creation' => 'Criação',
+ 'Expiration' => 'Expiração',
+ 'Password reset history' => 'Histórico da redefinição da senha',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Todas as tarefas da coluna "%s" e da Swimlane "%s" foram fechadas com sucesso.',
+ 'Do you really want to close all tasks of this column?' => 'Você realmente deseja fechar todas as tarefas desta coluna?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d tarefa(s) na coluna "%s" e na Swimlane "%s" serão fechadas.',
+ 'Close all tasks of this column' => 'Fechar todas as tarefas desta coluna',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php
index 540a4a56..82412733 100644
--- a/app/Locale/pt_PT/translations.php
+++ b/app/Locale/pt_PT/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Remover projecto',
'Edit the board for "%s"' => 'Editar o quadro para "%s"',
'All projects' => 'Todos os projectos',
- 'Change columns' => 'Modificar colunas',
'Add a new column' => 'Adicionar uma nova coluna',
'Title' => 'Título',
'Nobody assigned' => 'Ninguém assignado',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Abrir uma tarefa',
'Do you really want to open this task: "%s"?' => 'Tem a certeza que quer abrir esta tarefa: "%s"?',
'Back to the board' => 'Voltar ao quadro',
- 'Created on %B %e, %Y at %k:%M %p' => 'Criado em %d %B %Y às %H:%M',
'There is nobody assigned' => 'Não há ninguém assignado',
'Column on the board:' => 'Coluna no quadro:',
- 'Status is open' => 'Estado é aberto',
- 'Status is closed' => 'Estado é finalizado',
'Close this task' => 'Finalizar esta tarefa',
'Open this task' => 'Abrir esta tarefa',
'There is no description.' => 'Não há descrição.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'O ID é obrigatório',
'The project id is required' => 'O ID do projecto é obrigatório',
'The project name is required' => 'O nome do projecto é obrigatório',
- 'This project must be unique' => 'Este projecto deve ser único',
'The title is required' => 'O título é obrigatório',
'Settings saved successfully.' => 'Configurações guardadas com sucesso.',
'Unable to save your settings.' => 'Não é possível guardar as suas configurações.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Em andamento',
'Done' => 'Finalizado',
'Application version:' => 'Versão da aplicação:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Finalizado em %d %B %Y às %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d %B %Y às %H:%M',
- 'Date created' => 'Data de criação',
- 'Date completed' => 'Data da finalização',
'Id' => 'Id',
'%d closed tasks' => '%d tarefas finalizadas',
'No task for this project' => 'Não há tarefa para este projecto',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Complexidade',
'Task limit' => 'Limite da tarefa',
'Task count' => 'Número de tarefas',
- 'Edit project access list' => 'Editar lista de acesso ao projecto',
- 'Allow this user' => 'Permitir este utilizador',
- 'Don\'t forget that administrators have access to everything.' => 'Não se esqueça que administradores têm acesso a tudo.',
- 'Revoke' => 'Revogar',
- 'List of authorized users' => 'Lista de utilizadores autorizados',
'User' => 'Utilizador',
- 'Nobody have access to this project.' => 'Ninguém tem acesso a este projecto.',
'Comments' => 'Comentários',
'Write your text in Markdown' => 'Escreva o seu texto em Markdown',
'Leave a comment' => 'Deixe um comentário',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Editar esta tarefa',
'Due Date' => 'Data de vencimento',
'Invalid date' => 'Data inválida',
- 'Must be done before %B %e, %Y' => 'Deve ser finalizado antes de %d %B %Y',
- '%B %e, %Y' => '%d %B %Y',
- '%b %e, %Y' => '%d %B %Y',
'Automatic actions' => 'Acções automáticas',
'Your automatic action have been created successfully.' => 'A sua acção automática foi criada com sucesso.',
'Unable to create your automatic action.' => 'Não é possível criar a sua acção automática.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Designar uma cor para um utilizador específico',
'Column title' => 'Título da coluna',
'Position' => 'Posição',
- 'Move Up' => 'Mover para cima',
- 'Move Down' => 'Mover para baixo',
'Duplicate to another project' => 'Duplicar para outro projecto',
'Duplicate' => 'Duplicar',
'link' => 'link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Comentário removido com sucesso.',
'Unable to remove this comment.' => 'Não é possível remover este comentário.',
'Do you really want to remove this comment?' => 'Tem a certeza que quer remover este comentário?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Somente os administradores ou o criator deste comentário possuem acesso a esta página.',
'Current password for the user "%s"' => 'Senha atual para o utilizador "%s"',
'The current password is required' => 'A senha atual é obrigatória',
'Wrong password' => 'Senha incorreta',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'Autenticação externa falhou',
'Your external account is linked to your profile successfully.' => 'A sua conta externa foi vinculada com sucesso ao seu perfil',
'Email' => 'E-mail',
- 'Link my Google Account' => 'Vincular a minha Conta do Google',
- 'Unlink my Google Account' => 'Desvincular a minha Conta do Google',
- 'Login with my Google Account' => 'Entrar com a minha Conta do Google',
- 'Project not found.' => 'Projecto não encontrado.',
'Task removed successfully.' => 'Tarefa removida com sucesso.',
'Unable to remove this task.' => 'Não foi possível remover esta tarefa.',
'Remove a task' => 'Remover uma tarefa',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Tamanho máximo: ',
'Unable to upload the file.' => 'Não foi possível carregar o arquivo.',
'Display another project' => 'Mostrar outro projecto',
- 'Login with my Github Account' => 'Entrar com a minha Conta do Github',
- 'Link my Github Account' => 'Associar à minha Conta do Github',
- 'Unlink my Github Account' => 'Desassociar a minha Conta do Github',
'Created by %s' => 'Criado por %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Última modificação em %B %e, %Y às %k: %M %p',
'Tasks Export' => 'Exportar Tarefas',
'Tasks exportation for "%s"' => 'As tarefas foram exportadas para "%s"',
'Start Date' => 'Data inicial',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Quero receber notificações apenas destes projectos:',
'view the task on Kanboard' => 'ver a tarefa no Kanboard',
'Public access' => 'Acesso público',
- 'User management' => 'Gestão de utilizadores',
'Active tasks' => 'Tarefas activas',
'Disable public access' => 'Desactivar o acesso público',
'Enable public access' => 'Activar o acesso público',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'E-mail:',
'Notifications:' => 'Notificações:',
'Notifications' => 'Notificações',
- 'Group:' => 'Grupo:',
- 'Regular user' => 'Utilizador comum',
'Account type:' => 'Tipo de conta:',
'Edit profile' => 'Editar perfil',
'Change password' => 'Alterar senha',
'Password modification' => 'Alteração de senha',
'External authentications' => 'Autenticação externa',
- 'Google Account' => 'Conta do Google',
- 'Github Account' => 'Conta do Github',
'Never connected.' => 'Nunca conectado.',
- 'No account linked.' => 'Nenhuma conta associada.',
- 'Account linked.' => 'Conta associada.',
'No external authentication enabled.' => 'Nenhuma autenticação externa activa.',
'Password modified successfully.' => 'Senha alterada com sucesso.',
'Unable to change the password.' => 'Não foi possível alterar a senha.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s mudou a assignação da tarefa %s para %s',
'New password for the user "%s"' => 'Nova senha para o utilizador "%s"',
'Choose an event' => 'Escolher um evento',
- 'Github commit received' => 'Recebido commit do Github',
- 'Github issue opened' => 'Problema aberto no Github',
- 'Github issue closed' => 'Problema fechado no Github',
- 'Github issue reopened' => 'Problema reaberto no Github',
- 'Github issue assignee change' => 'Alterar assignação ao problema no Githubnge',
- 'Github issue label change' => 'Alterar etiqueta do problema no Github',
'Create a task from an external provider' => 'Criar uma tarefa por meio de um serviço externo',
'Change the assignee based on an external username' => 'Alterar assignação com base num utilizador externo',
'Change the category based on an external label' => 'Alterar categoria com base num rótulo externo',
'Reference' => 'Referência',
- 'Reference: %s' => 'Referência: %s',
'Label' => 'Rótulo',
'Database' => 'Base de dados',
'About' => 'Sobre',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frequência em segundos (60 segundos por defeito)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frequência em segundos (0 para desactivar este recurso, 10 segundos por defeito)',
'Application URL' => 'URL da Aplicação',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Exemplo: http://example.kanboard.net/ (utilizado nas notificações por e-mail)',
'Token regenerated.' => 'Token ',
'Date format' => 'Formato da data',
'ISO format is always accepted, example: "%s" and "%s"' => 'O formato ISO é sempre aceite, exemplo: "%s" e "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Este projecto é privado',
'Type here to create a new sub-task' => 'Escreva aqui para criar uma nova subtarefa',
'Add' => 'Adicionar',
- 'Estimated time: %s hours' => 'Tempo estimado: %s horas',
- 'Time spent: %s hours' => 'Tempo gasto: %s horas',
- 'Started on %B %e, %Y' => 'Iniciado em %B %e, %Y',
'Start date' => 'Data de início',
'Time estimated' => 'Tempo estimado',
'There is nothing assigned to you.' => 'Não há nada assignado a si.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Todos possuem acesso a este projecto.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github webhooks',
- 'Help on Github webhooks' => 'Ajuda para o Github webhooks',
'Create a comment from an external provider' => 'Criar um comentário por meio de um serviço externo',
- 'Github issue comment created' => 'Criado comentário ao problema no Github',
'Project management' => 'Gestão de projectos',
'My projects' => 'Os meus projectos',
'Columns' => 'Colunas',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Redistribuição de utilizador para "%s"',
'Clone this project' => 'Clonar este projecto',
'Column removed successfully.' => 'Coluna removida com sucesso.',
- 'Github Issue' => 'Problema no Github',
'Not enough data to show the graph.' => 'Não há dados suficientes para mostrar o gráfico.',
'Previous' => 'Anterior',
'The id must be an integer' => 'O ID deve ser um número inteiro',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Swimlane padrão',
'Do you really want to remove this swimlane: "%s"?' => 'Tem a certeza que quer remover este swimlane: "%s"?',
'Inactive swimlanes' => 'Desactivar swimlanes',
- 'Set project manager' => 'Definir gerente do projecto',
- 'Set project member' => 'Definir membro do projecto',
'Remove a swimlane' => 'Remover um swimlane',
- 'Rename' => 'Renomear',
'Show default swimlane' => 'Mostrar swimlane padrão',
'Swimlane modification for the project "%s"' => 'Modificação de swimlane para o projecto "%s"',
'Swimlane not found.' => 'Swimlane não encontrado.',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane atualizado com sucesso.',
'The default swimlane have been updated successfully.' => 'O swimlane padrão foi atualizado com sucesso.',
- 'Unable to create your swimlane.' => 'Não foi possível criar o seu swimlane.',
'Unable to remove this swimlane.' => 'Não foi possível remover este swimlane.',
'Unable to update this swimlane.' => 'Não foi possível atualizar este swimlane.',
'Your swimlane have been created successfully.' => 'Seu swimlane foi criado com sucesso.',
'Example: "Bug, Feature Request, Improvement"' => 'Exemplo: "Bug, Feature Request, Improvement"',
'Default categories for new projects (Comma-separated)' => 'Categorias padrão para novos projectos (Separadas por vírgula)',
- 'Gitlab commit received' => 'Commit recebido do Gitlab',
- 'Gitlab issue opened' => 'Problema aberto no Gitlab',
- 'Gitlab issue closed' => 'Problema fechado no Gitlab',
- 'Gitlab webhooks' => 'Gitlab webhooks',
- 'Help on Gitlab webhooks' => 'Ajuda sobre Gitlab webhooks',
'Integrations' => 'Integrações',
'Integration with third-party services' => 'Integração com serviços de terceiros',
- 'Role for this project' => 'Função para este projecto',
- 'Project manager' => 'Gerente do projecto',
- 'Project member' => 'Membro do projecto',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Um gerente do projecto pode alterar as configurações do projecto e ter mais privilégios que um utilizador padrão.',
- 'Gitlab Issue' => 'Problema Gitlab',
'Subtask Id' => 'ID da subtarefa',
'Subtasks' => 'Subtarefas',
'Subtasks Export' => 'Exportar subtarefas',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Já tem uma subtarefa em andamento',
'Which parts of the project do you want to duplicate?' => 'Quais as partes do projecto que deseja duplicar?',
'Disallow login form' => 'Desactivar login',
- 'Bitbucket commit received' => '"Commit" recebido via Bitbucket',
- 'Bitbucket webhooks' => 'Webhook Bitbucket',
- 'Help on Bitbucket webhooks' => 'Ajuda sobre os webhooks Bitbucket',
'Start' => 'Inicio',
'End' => 'Fim',
'Task age in days' => 'Idade da tarefa em dias',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Esta tarefa',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%e %b',
'Expand tasks' => 'Expandir tarefas',
'Collapse tasks' => 'Contrair tarefas',
'Expand/collapse tasks' => 'Expandir/Contrair tarefas',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Atalhos de teclado',
'Open board switcher' => 'Abrir o comutador de painel',
'Application' => 'Aplicação',
- 'since %B %e, %Y at %k:%M %p' => 'desde o %d/%m/%Y às %H:%M',
'Compact view' => 'Vista reduzida',
'Horizontal scrolling' => 'Deslocamento horizontal',
'Compact/wide view' => 'Alternar entre a vista compacta e ampliada',
'No results match:' => 'Nenhum resultado:',
'Currency' => 'Moeda',
- 'Files' => 'Arquivos',
- 'Images' => 'Imagens',
'Private project' => 'Projecto privado',
'AUD - Australian Dollar' => 'AUD - Dólar australiano',
'CAD - Canadian Dollar' => 'CAD - Dólar canadense',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'O código de autenticação com factor duplo é válido',
'Code' => 'Código',
'Two factor authentication' => 'Autenticação com factor duplo',
- 'Enable/disable two factor authentication' => 'Activar/Desactivar autenticação com factor duplo',
'This QR code contains the key URI: ' => 'Este Código QR contém a chave URI: ',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Guarde esta chave secreta no seu software TOTP (por exemplo Google Authenticator ou FreeOTP).',
'Check my code' => 'Verificar o meu código',
'Secret key: ' => 'Chave secreta: ',
'Test your device' => 'Teste o seu dispositivo',
'Assign a color when the task is moved to a specific column' => 'Atribuir uma cor quando a tarefa é movida em uma coluna específica',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'carregado por: %s',
- 'uploaded on: %s' => 'carregado em: %s',
- 'size: %s' => 'tamanho: %s',
'Burndown chart for "%s"' => 'Gráfico de Burndown para "%s"',
'Burndown chart' => 'Gráfico de Burndown',
'This chart show the task complexity over the time (Work Remaining).' => 'Este gráfico mostra a complexidade da tarefa ao longo do tempo (Trabalho Restante).',
@@ -722,7 +645,6 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Tire um screenshot e pressione CTRL + V ou ⌘ + V para colar aqui.',
'Screenshot uploaded successfully.' => 'Screenshot enviada com sucesso.',
'SEK - Swedish Krona' => 'SEK - Coroa sueca',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'O identificador de projecto é um código alfanumérico opcional utilizado para identificar o seu projecto.',
'Identifier' => 'Identificador',
'Disable two factor authentication' => 'Desactivar autenticação com dois factores',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Tem a certeza que quer desactivar a autenticação com dois factores para esse utilizador: "%s"?',
@@ -731,7 +653,6 @@ return array(
'A task cannot be linked to itself' => 'Uma tarefa não pode ser ligada a si própria',
'The exact same link already exists' => 'Um link idêntico jà existe',
'Recurrent task is scheduled to be generated' => 'A tarefa recorrente está programada para ser criada',
- 'Recurring information' => 'Informação sobre a recorrência',
'Score' => 'Complexidade',
'The identifier must be unique' => 'O identificador deve ser único',
'This linked task id doesn\'t exists' => 'O identificador da tarefa associada não existe',
@@ -739,20 +660,20 @@ return array(
'Edit recurrence' => 'Modificar a recorrência',
'Generate recurrent task' => 'Gerar uma tarefa recorrente',
'Trigger to generate recurrent task' => 'Activador para gerar tarefa recorrente',
- 'Factor to calculate new due date' => 'Factor para o cálculo do nova data limite',
- 'Timeframe to calculate new due date' => 'Escala de tempo para o cálculo da nova data limite',
- 'Base date to calculate new due date' => 'Data a ser utilizada para calcular a nova data limite',
+ 'Factor to calculate new due date' => 'Factor para o cálculo do nova data de vencimento',
+ 'Timeframe to calculate new due date' => 'Escala de tempo para o cálculo da nova data de vencimento',
+ 'Base date to calculate new due date' => 'Data a ser utilizada para calcular a nova data de vencimento',
'Action date' => 'Data da acção',
- 'Base date to calculate new due date: ' => 'Data a ser utilizada para calcular a nova data limite: ',
+ 'Base date to calculate new due date: ' => 'Data a ser utilizada para calcular a nova data de vencimento: ',
'This task has created this child task: ' => 'Esta tarefa criou a tarefa filha: ',
'Day(s)' => 'Dia(s)',
- 'Existing due date' => 'Data limite existente',
- 'Factor to calculate new due date: ' => 'Factor para calcular a nova data limite: ',
+ 'Existing due date' => 'Data de vencimento existente',
+ 'Factor to calculate new due date: ' => 'Factor para calcular a nova data de vencimento: ',
'Month(s)' => 'Mês(es)',
'Recurrence' => 'Recorrência',
'This task has been created by: ' => 'Esta tarefa foi criada por: ',
'Recurrent task has been generated:' => 'A tarefa recorrente foi gerada:',
- 'Timeframe to calculate new due date: ' => 'Escala de tempo para o cálculo da nova data limite: ',
+ 'Timeframe to calculate new due date: ' => 'Escala de tempo para o cálculo da nova data de vencimento: ',
'Trigger to generate recurrent task: ' => 'Activador para gerar tarefa recorrente: ',
'When task is closed' => 'Quando a tarefa é fechada',
'When task is moved from first column' => 'Quando a tarefa é movida fora da primeira coluna',
@@ -777,21 +698,10 @@ return array(
'User that will receive the email' => 'O utilizador que vai receber o e-mail',
'Email subject' => 'Assunto do e-mail',
'Date' => 'Data',
- 'By @%s on Bitbucket' => 'Por @%s no Bitbucket',
- 'Bitbucket Issue' => 'Problema Bitbucket',
- 'Commit made by @%s on Bitbucket' => 'Commit feito por @%s no Bitbucket',
- 'Commit made by @%s on Github' => 'Commit feito por @%s no Github',
- 'By @%s on Github' => 'Por @%s no Github',
- 'Commit made by @%s on Gitlab' => 'Commit feito por @%s no Gitlab',
'Add a comment log when moving the task between columns' => 'Adicionar um comentário de log quando uma tarefa é movida para uma outra coluna',
'Move the task to another column when the category is changed' => 'Mover uma tarefa para outra coluna quando a categoria mudar',
'Send a task by email to someone' => 'Enviar uma tarefa por e-mail a alguém',
'Reopen a task' => 'Reabrir uma tarefa',
- 'Bitbucket issue opened' => 'Problema aberto no Bitbucket',
- 'Bitbucket issue closed' => 'Problema fechado no Bitbucket',
- 'Bitbucket issue reopened' => 'Problema reaberto no Bitbucket',
- 'Bitbucket issue assignee change' => 'Alterar assignação do problema no Bitbucket',
- 'Bitbucket issue comment created' => 'Comentário ao problema adicionado ao Bitbucket',
'Column change' => 'Mudança de coluna',
'Position change' => 'Mudança de posição',
'Swimlane change' => 'Mudança de swimlane',
@@ -818,7 +728,7 @@ return array(
'New category: %s' => 'Nova categoria: %s',
'New color: %s' => 'Nova cor: %s',
'New complexity: %d' => 'Nova complexidade: %d',
- 'The due date have been removed' => 'A data limite foi retirada',
+ 'The due date have been removed' => 'A data de vencimento foi retirada',
'There is no description anymore' => 'Já não há descrição',
'Recurrence settings have been modified' => 'As configurações da recorrência foram modificadas',
'Time spent changed: %sh' => 'O tempo despendido foi mudado: %sh',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'O campo "%s" foi actualizada',
'The description have been modified' => 'A descrição foi modificada',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Tem a certeza que quer fechar a tarefa "%s" e todas as suas sub-tarefas?',
- 'Swimlane: %s' => 'Swimlane: %s',
'I want to receive notifications for:' => 'Eu quero receber as notificações para:',
'All tasks' => 'Todas as tarefas',
'Only for tasks assigned to me' => 'Somente as tarefas atribuídas a mim',
'Only for tasks created by me' => 'Apenas as tarefas que eu criei',
'Only for tasks created by me and assigned to me' => 'Apenas as tarefas que eu criei e aquelas atribuídas a mim',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%d/%m/%Y %H:%M',
- 'New due date: %B %e, %Y' => 'Nova data limite: %d/%m/%Y',
- 'Start date changed: %B %e, %Y' => 'Data de início alterada: %d/%m/%Y',
- '%k:%M %p' => '%H:%M',
'%%Y-%%m-%%d' => '%%d/%%m/%%Y',
'Total for all columns' => 'Total para todas as colunas',
'You need at least 2 days of data to show the chart.' => 'Precisa de pelo menos 2 dias de dados para visualizar o gráfico.',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Não assignada',
'View advanced search syntax' => 'Ver sintaxe avançada de pesquisa',
'Overview' => 'Visão global',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Vista Painel/Calendário/Lista',
'Switch to the board view' => 'Mudar para o modo Painel',
'Switch to the calendar view' => 'Mudar para o modo Calendário',
@@ -880,7 +783,7 @@ return array(
'Search by color: ' => 'Pesquisar por cor: ',
'Search by category: ' => 'Pesquisar por categoria: ',
'Search by description: ' => 'Pesquisar por descrição: ',
- 'Search by due date: ' => 'Pesquisar por data de expiração: ',
+ 'Search by due date: ' => 'Pesquisar por data de vencimento: ',
'Lead and Cycle time for "%s"' => 'Tempo de Espera e Ciclo para "%s"',
'Average time spent into each column for "%s"' => 'Tempo médio gasto em cada coluna para "%s"',
'Average time spent into each column' => 'Tempo médio gasto em cada coluna',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Este gráfico mostra o tempo médio de espera e ciclo para as últimas %d tarefas realizadas.',
'Average time into each column' => 'Tempo médio em cada coluna',
'Lead and cycle time' => 'Tempo de Espera e Ciclo',
- 'Google Authentication' => 'Autenticação Google',
- 'Help on Google authentication' => 'Ajuda com autenticação Google',
- 'Github Authentication' => 'Autenticação Github',
- 'Help on Github authentication' => 'Ajuda com autenticação Github',
'Lead time: ' => 'Tempo de Espera: ',
'Cycle time: ' => 'Tempo de Ciclo: ',
'Time spent into each column' => 'Tempo gasto em cada coluna',
@@ -906,18 +805,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Se a tarefa não estiver fechada o hora actual será usada em vez da hora de conclusão',
'Set automatically the start date' => 'Definir data de inicio automáticamente',
'Edit Authentication' => 'Editar Autenticação',
- 'Google Id' => 'Id Google',
- 'Github Id' => 'Id Github',
'Remote user' => 'Utilizador remoto',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Utilizadores remotos não guardam a password na base de dados do Kanboard, por exemplo: LDAP, contas do Google e Github.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Se activar a opção "Desactivar login", as credenciais digitadas no login serão ignoradas.',
- 'By @%s on Gitlab' => 'Por @%s no Gitlab',
- 'Gitlab issue comment created' => 'Comentário a problema no Gitlab adicionado',
'New remote user' => 'Novo utilizador remoto',
'New local user' => 'Novo utilizador local',
'Default task color' => 'Cor de tarefa por defeito',
- 'Hide sidebar' => 'Esconder barra lateral',
- 'Expand sidebar' => 'Expandir barra lateral',
'This feature does not work with all browsers.' => 'Esta funcionalidade não funciona em todos os browsers',
'There is no destination project available.' => 'Não há projecto de destino disponivel',
'Trigger automatically subtask time tracking' => 'Activar automáticamente subtarefa de controlo de tempo',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'contribuidores',
'License:' => 'Licença:',
'License' => 'Licença',
- 'Project Administrator' => 'Administrador do Projecto',
'Enter the text below' => 'Escreva o texto em baixo',
'Gantt chart for %s' => 'Gráfico de Gantt para %s',
'Sort by position' => 'Ordenar por posição',
@@ -945,18 +837,16 @@ return array(
'There is no task in your project.' => 'Não existe tarefa no seu projecto.',
'Gantt chart' => 'Gráfico de Gantt',
'People who are project managers' => 'Pessoas que são gestores do projecto',
- 'People who are project members' => 'Pessoas que são membors do projecto',
+ 'People who are project members' => 'Pessoas que são membros do projecto',
'NOK - Norwegian Krone' => 'NOK - Coroa Norueguesa',
'Show this column' => 'Mostrar esta coluna',
'Hide this column' => 'Esconder esta coluna',
'open file' => 'abrir ficheiro',
'End date' => 'Data de fim',
'Users overview' => 'Visão geral de Utilizadores',
- 'Managers' => 'Gestores',
'Members' => 'Membros',
'Shared project' => 'Projecto partilhado',
'Project managers' => 'Gestores do projecto',
- 'Project members' => 'Membros do projecto',
'Gantt chart for all projects' => 'Gráfico de Gantt para todos os projectos',
'Projects list' => 'Lista de projectos',
'Gantt chart for this project' => 'Gráfico de Gantt para este projecto',
@@ -964,26 +854,16 @@ return array(
'End date:' => 'Data de fim:',
'There is no start date or end date for this project.' => 'Não existe data de inicio ou fim para este projecto.',
'Projects Gantt chart' => 'Gráfico de Gantt dos projectos',
- 'Start date: %s' => 'Data de inicio: %s',
- 'End date: %s' => 'Data de fim: %s',
'Link type' => 'Tipo de ligação',
'Change task color when using a specific task link' => 'Alterar cor da tarefa quando se usar um tipo especifico de ligação de tarefa',
'Task link creation or modification' => 'Criação ou modificação de ligação de tarefa',
- 'Login with my Gitlab Account' => 'Login com a minha Conta Gitlab',
'Milestone' => 'Objectivo',
- 'Gitlab Authentication' => 'Autenticação Gitlab',
- 'Help on Gitlab authentication' => 'Ajuda com autenticação Gitlab',
- 'Gitlab Id' => 'Id Gitlab',
- 'Gitlab Account' => 'Conta Gitlab',
- 'Link my Gitlab Account' => 'Connectar a minha Conta Gitlab',
- 'Unlink my Gitlab Account' => 'Desconectar a minha Conta Gitlab',
'Documentation: %s' => 'Documentação: %s',
'Switch to the Gantt chart view' => 'Mudar para vista de gráfico de Gantt',
'Reset the search/filter box' => 'Repor caixa de procura/filtro',
'Documentation' => 'Documentação',
'Table of contents' => 'Tabela de conteúdos',
'Gantt' => 'Gantt',
- 'Help with project permissions' => 'Ajuda com permissões de projecto',
'Author' => 'Autor',
'Version' => 'Versão',
'Plugins' => 'Extras',
@@ -1027,41 +907,245 @@ return array(
'Owner' => 'Dono',
'Unread notifications' => 'Notificações por ler',
'My filters' => 'Os meus filtros',
- 'Notification methods:' => 'Metodos de notificação:',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'Notification methods:' => 'Métodos de notificação:',
+ 'Import tasks from CSV file' => 'Importar tarefas de um ficheiro CSV',
+ 'Unable to read your file' => 'Não foi possivel ler o ficheiro',
+ '%d task(s) have been imported successfully.' => '%d tarefa(s) importada(s) com successo.',
+ 'Nothing have been imported!' => 'Nada foi importado',
+ 'Import users from CSV file' => 'Importar utilizadores de um ficheiro CSV',
+ '%d user(s) have been imported successfully.' => '%d utilizadore(s) importados com successo.',
+ 'Comma' => 'Vírgula',
+ 'Semi-colon' => 'Ponto e Vírgula',
+ 'Tab' => 'Tabulação',
+ 'Vertical bar' => 'Barra vertical',
+ 'Double Quote' => 'Aspas',
+ 'Single Quote' => 'Plica',
+ '%s attached a file to the task #%d' => '%s anexou um ficheiro à tarefa #%d',
+ 'There is no column or swimlane activated in your project!' => 'Não existe nenhuma coluna ou swimlane activado no seu projecto!',
+ 'Append filter (instead of replacement)' => 'Acrescentar filtro (em vez de substituir)',
+ 'Append/Replace' => 'Acrescentar/Substituir',
+ 'Append' => 'Acrescentar',
+ 'Replace' => 'Substituir',
+ 'Import' => 'Importar',
+ 'change sorting' => 'alterar ordernação',
+ 'Tasks Importation' => 'Importação de Tarefas',
+ 'Delimiter' => 'Delimitador',
+ 'Enclosure' => 'Clausura',
+ 'CSV File' => 'Ficheiro CSV',
+ 'Instructions' => 'Instruções',
+ 'Your file must use the predefined CSV format' => 'O seu ficheiro tem de usar um formato CSV pre-definido',
+ 'Your file must be encoded in UTF-8' => 'O seu ficheiro tem de estar codificado como UTF-8',
+ 'The first row must be the header' => 'A primeira linha tem de ser o cabeçalho',
+ 'Duplicates are not verified for you' => 'Duplicados não são verificados por si',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'A data de expiração tem de estar no formato ISO: AAAA-MM-DD',
+ 'Download CSV template' => 'Descarregar template CSV',
+ 'No external integration registered.' => 'Nenhuma integração externa registrada.',
+ 'Duplicates are not imported' => 'Duplicados não são importados',
+ 'Usernames must be lowercase and unique' => 'Utilizadores tem de estar em letra pequena e ser unicos',
+ 'Passwords will be encrypted if present' => 'Senhas serão encriptadas se presentes',
+ '%s attached a new file to the task %s' => '%s anexou um novo ficheiro à tarefa %s',
+ 'Assign automatically a category based on a link' => 'Assignar automáticamente a categoria baseada num link',
+ 'BAM - Konvertible Mark' => 'BAM - Marca Conversível',
+ 'Assignee Username' => 'Utilizador do Assignado',
+ 'Assignee Name' => 'Nome do Assignado',
+ 'Groups' => 'Grupos',
+ 'Members of %s' => 'Membros de %s',
+ 'New group' => 'Novo grupo',
+ 'Group created successfully.' => 'Grupo criado com sucesso.',
+ 'Unable to create your group.' => 'Não foi possivel criar o seu grupo.',
+ 'Edit group' => 'Editar grupo',
+ 'Group updated successfully.' => 'Grupo actualizado com sucesso.',
+ 'Unable to update your group.' => 'Não foi possivel actualizar o seu grupo.',
+ 'Add group member to "%s"' => 'Adicionar membro do grupo a "%s"',
+ 'Group member added successfully.' => 'Membro de grupo adicionado com sucesso.',
+ 'Unable to add group member.' => 'Não foi possivel adicionar membro de grupo.',
+ 'Remove user from group "%s"' => 'Remover utilizador do grupo "%s"',
+ 'User removed successfully from this group.' => 'Utilizador removido com sucesso deste grupo.',
+ 'Unable to remove this user from the group.' => 'Não foi possivel remover este utilizador do grupo.',
+ 'Remove group' => 'Remover grupo.',
+ 'Group removed successfully.' => 'Grupo removido com sucesso.',
+ 'Unable to remove this group.' => 'Não foi possivel remover este grupo.',
+ 'Project Permissions' => 'Permissões de Projecto',
+ 'Manager' => 'Gestor',
+ 'Project Manager' => 'Gestor de Projecto',
+ 'Project Member' => 'Membro de Projecto',
+ 'Project Viewer' => 'Visualizador de Projecto',
+ 'Your account is locked for %d minutes' => 'A sua conta está bloqueada por %d minutos',
+ 'Invalid captcha' => 'Captcha inválido',
+ 'The name must be unique' => 'O nome deve ser único',
+ 'View all groups' => 'Ver todos os grupos',
+ 'View group members' => 'Ver membros do grupo',
+ 'There is no user available.' => 'Não existe utilizador disponivel.',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Tem a certeza que quer remover o utilizador "%s" do grupo "%s"?',
+ 'There is no group.' => 'Não existe grupo.',
+ 'External Id' => 'Id externo',
+ 'Add group member' => 'Adicionar membro de grupo',
+ 'Do you really want to remove this group: "%s"?' => 'Tem a certeza que quer remover este grupo: "%s"?',
+ 'There is no user in this group.' => 'Não existe utilizadores neste grupo.',
+ 'Remove this user' => 'Remover este utilizador',
+ 'Permissions' => 'Permissões',
+ 'Allowed Users' => 'Utilizadores Permitidos',
+ 'No user have been allowed specifically.' => 'Nenhum utilizador foi especificamente permitido.',
+ 'Role' => 'Função',
+ 'Enter user name...' => 'Escreva o nome do utilizador...',
+ 'Allowed Groups' => 'Grupos Permitidos',
+ 'No group have been allowed specifically.' => 'Nenhum grupo foi especificamente permitido.',
+ 'Group' => 'Grupo',
+ 'Group Name' => 'Nome do Grupo',
+ 'Enter group name...' => 'Escreva o nome do Grupo',
+ 'Role:' => 'Função:',
+ 'Project members' => 'Membros do projecto',
+ 'Compare hours for "%s"' => 'Comparar horas para "%s"',
+ '%s mentioned you in the task #%d' => '%s mencionou-te na tarefa #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s mencionou-te num comentário na tarefa #%d',
+ 'You were mentioned in the task #%d' => 'Foi mencionado na tarefa #%d',
+ 'You were mentioned in a comment on the task #%d' => 'Foi mencionado num comentário na tarefa #%d',
+ 'Mentioned' => 'Mencionado',
+ 'Compare Estimated Time vs Actual Time' => 'Comparar Tempo Estimado vs Tempo Real',
+ 'Estimated hours: ' => 'Horas estimadas: ',
+ 'Actual hours: ' => 'Horas reais: ',
+ 'Hours Spent' => 'Horas Gastas',
+ 'Hours Estimated' => 'Horas Estimadas',
+ 'Estimated Time' => 'Tempo Estimado',
+ 'Actual Time' => 'Tempo Real',
+ 'Estimated vs actual time' => 'Tempo estimado vs real',
+ 'RUB - Russian Ruble' => 'RUB - Rublo Russo',
+ 'Assign the task to the person who does the action when the column is changed' => 'Assignar a tarefa à pessoa que realiza a acção quando a coluna é alterada',
+ 'Close a task in a specific column' => 'Fechar tarefa numa coluna especifica',
+ 'Time-based One-time Password Algorithm' => 'Algoritmo de password para uso único baseado em tempo',
+ 'Two-Factor Provider: ' => 'Provedor de Dois Passos: ',
+ 'Disable two-factor authentication' => 'Desactivar autenticação de dois passos',
+ 'Enable two-factor authentication' => 'Activar autenticação de dois passos',
+ 'There is no integration registered at the moment.' => 'Não existe nenhuma integração registrada até ao momento.',
+ 'Password Reset for Kanboard' => 'Redefinir Password para Kanboard',
+ 'Forgot password?' => 'Esqueceu a password?',
+ 'Enable "Forget Password"' => 'Activar "Esqueceu a password"',
+ 'Password Reset' => 'Redefinir a Password',
+ 'New password' => 'Nova Password',
+ 'Change Password' => 'Alterar Password',
+ 'To reset your password click on this link:' => 'Para redefinir a sua password click nesta ligação:',
+ 'Last Password Reset' => 'Última Redefinição da Password',
+ 'The password has never been reinitialized.' => 'A password nunca foi redefinida.',
+ 'Creation' => 'Criação',
+ 'Expiration' => 'Expiração',
+ 'Password reset history' => 'Histórico da redefinição da password',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Todas as tarefas na coluna "%s" e na swimlane "%s" foram fechadas com successo.',
+ 'Do you really want to close all tasks of this column?' => 'Tem a certeza que quer fechar todas as tarefas nesta coluna?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d tarefa(s) na coluna "%s" e na swimlane "%s" serão fechadas.',
+ 'Close all tasks of this column' => 'Fechar todas as tarefas nesta coluna',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Nenhum plugin tem registado um método de notificação do projecto. Pode continuar a configurar notificações individuais no seu perfil de utilizador.',
+ 'My dashboard' => 'Meu painel',
+ 'My profile' => 'Meu perfil',
+ 'Project owner: ' => 'Dono do projecto: ',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'O identificador do projecto é opcional e tem de ser alfa-numerico, exemplo: MEUPROJECTO.',
+ 'Project owner' => 'Dono do projecto',
+ 'Those dates are useful for the project Gantt chart.' => 'Estas datas são uteis para o gráfico de Grantt do projecto.',
+ 'Private projects do not have users and groups management.' => 'Projectos privados não têm gestão de utilizadores nem de grupos.',
+ 'There is no project member.' => 'Não existe membro do projecto.',
+ 'Priority' => 'Prioridade',
+ 'Task priority' => 'Prioridade da Tarefa',
+ 'General' => 'Geral',
+ 'Dates' => 'Datas',
+ 'Default priority' => 'Prioridade por defeito',
+ 'Lowest priority' => 'Prioridade mais baixa',
+ 'Highest priority' => 'Prioridade mais alta',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'Se colocar zero na prioridade baixa ou alta, essa funcionalidade será desactivada.',
+ 'Close a task when there is no activity' => 'Fechar tarefa quando não há actividade',
+ 'Duration in days' => 'Duração em dias',
+ 'Send email when there is no activity on a task' => 'Enviar email quando não há actividade numa tarefa',
+ 'List of external links' => 'Lista de ligações externas',
+ 'Unable to fetch link information.' => 'Impossivel obter informação da ligação.',
+ 'Daily background job for tasks' => 'Trabalho diário em segundo plano para tarefas',
+ 'Auto' => 'Auto',
+ 'Related' => 'Relacionado',
+ 'Attachment' => 'Anexo',
+ 'Title not found' => 'Titulo não encontrado',
+ 'Web Link' => 'Ligação Web',
+ 'External links' => 'Ligações externas',
+ 'Add external link' => 'Adicionar ligação externa',
+ 'Type' => 'Tipo',
+ 'Dependency' => 'Dependencia',
+ 'Add internal link' => 'Adicionar ligação interna',
+ 'Add a new external link' => 'Adicionar nova ligação externa',
+ 'Edit external link' => 'Editar ligação externa',
+ 'External link' => 'Ligação externa',
+ 'Copy and paste your link here...' => 'Copie e cole a sua ligação aqui...',
+ 'URL' => 'URL',
+ 'There is no external link for the moment.' => 'De momento não existe nenhuma ligação externa.',
+ 'Internal links' => 'Ligações internas',
+ 'There is no internal link for the moment.' => 'De momento não existe nenhuma ligação interna.',
+ 'Assign to me' => 'Assignar a mim',
+ 'Me' => 'Eu',
+ 'Do not duplicate anything' => 'Não duplicar nada',
+ 'Projects management' => 'Gestão de projectos',
+ 'Users management' => 'Gestão de utilizadores',
+ 'Groups management' => 'Gestão de grupos',
+ 'Create from another project' => 'Criar apartir de outro projecto',
+ 'There is no subtask at the moment.' => 'De momento não existe sub-tarefa.',
+ 'open' => 'aberto',
+ 'closed' => 'fechado',
+ 'Priority:' => 'Prioridade:',
+ 'Reference:' => 'Referência:',
+ 'Complexity:' => 'Complexidade:',
+ 'Swimlane:' => 'Swimlane:',
+ 'Column:' => 'Coluna:',
+ 'Position:' => 'Posição:',
+ 'Creator:' => 'Criador:',
+ 'Time estimated:' => 'Tempo estimado:',
+ '%s hours' => '%s horas',
+ 'Time spent:' => 'Tempo gasto:',
+ 'Created:' => 'Criado:',
+ 'Modified:' => 'Modificado:',
+ 'Completed:' => 'Completado:',
+ 'Started:' => 'Iniciado:',
+ 'Moved:' => 'Movido:',
+ 'Task #%d' => 'Tarefa #%d',
+ 'Sub-tasks' => 'Sub-tarefa',
+ 'Date and time format' => 'Formato tempo e data',
+ 'Time format' => 'Formato tempo',
+ 'Start date: ' => 'Data inicio: ',
+ 'End date: ' => 'Data final: ',
+ 'New due date: ' => 'Nova data estimada: ',
+ 'Start date changed: ' => 'Data inicio alterada: ',
+ 'Disable private projects' => 'Desactivar projectos privados',
+ 'Do you really want to remove this custom filter: "%s"?' => 'Tem a certeza que quer remover este filtro personalizado: "%s"?',
+ 'Remove a custom filter' => 'Remover o filtro personalizado',
+ 'User activated successfully.' => 'Utilizador activado com sucesso.',
+ 'Unable to enable this user.' => 'Não foi possivel activar este utilizador.',
+ 'User disabled successfully.' => 'Utilizador desactivado com sucesso.',
+ 'Unable to disable this user.' => 'Não foi possivel desactivar este utilizador.',
+ 'All files have been uploaded successfully.' => 'Todos os ficheiros foram enviados com sucesso.',
+ 'View uploaded files' => 'Ver ficheiros enviados',
+ 'The maximum allowed file size is %sB.' => 'O tamanho máximo permitido é %sB.',
+ 'Choose files again' => 'Escolher ficheiros novamente',
+ 'Drag and drop your files here' => 'Arraste e deixe os ficheiros para aqui',
+ 'choose files' => 'escolher ficheiros',
+ 'View profile' => 'Ver perfil',
+ 'Two Factor' => 'Dois Factores',
+ 'Disable user' => 'Desactivar utilizador',
+ 'Do you really want to disable this user: "%s"?' => 'Tem a certeza que quer desactivar este utilizador: "%s"?',
+ 'Enable user' => 'Activar utilizador',
+ 'Do you really want to enable this user: "%s"?' => 'Tem a certeza que quer activar este utilizador: "%s"?',
+ 'Download' => 'Transferir',
+ 'Uploaded: %s' => 'Enviado: %s',
+ 'Size: %s' => 'Tamanho: %s',
+ 'Uploaded by %s' => 'Enviado por %s',
+ 'Filename' => 'Nome do ficheiro',
+ 'Size' => 'Tamanho',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php
index 0e9f2da8..0d73f903 100644
--- a/app/Locale/ru_RU/translations.php
+++ b/app/Locale/ru_RU/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Удалить проект',
'Edit the board for "%s"' => 'Изменить доску для "%s"',
'All projects' => 'Все проекты',
- 'Change columns' => 'Изменить колонки',
'Add a new column' => 'Добавить новую колонку',
'Title' => 'Название',
'Nobody assigned' => 'Никто не назначен',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Открыть задачу',
'Do you really want to open this task: "%s"?' => 'Вы уверены что хотите открыть задачу: "%s" ?',
'Back to the board' => 'Вернуться на доску',
- 'Created on %B %e, %Y at %k:%M %p' => 'Создано %B /%e /%Y в %k:%M %p',
'There is nobody assigned' => 'Никто не назначен',
'Column on the board:' => 'Колонка на доске: ',
- 'Status is open' => 'Статус - открыт',
- 'Status is closed' => 'Статус - закрыт',
'Close this task' => 'Закрыть задачу',
'Open this task' => 'Открыть задачу',
'There is no description.' => 'Нет описания.',
@@ -120,11 +116,10 @@ return array(
'The user id is required' => 'Необходим ID пользователя',
'Passwords don\'t match' => 'Пароли не совпадают',
'The confirmation is required' => 'Необходимо подтверждение',
- 'The project is required' => 'Необъодимо указать проект',
+ 'The project is required' => 'Необходимо указать проект',
'The id is required' => 'Необходим ID',
'The project id is required' => 'Необходим ID проекта',
'The project name is required' => 'Необходимо имя проекта',
- 'This project must be unique' => 'Проект должен быть уникальным',
'The title is required' => 'Необходим заголовок',
'Settings saved successfully.' => 'Параметры успешно сохранены.',
'Unable to save your settings.' => 'Невозможно сохранить параметры.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'В процессе',
'Done' => 'Выполнено',
'Application version:' => 'Версия приложения:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Завершен %d/%m/%Y в %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d/%m/%Y в %H:%M',
- 'Date created' => 'Дата создания',
- 'Date completed' => 'Дата завершения',
'Id' => 'ID',
'%d closed tasks' => '%d завершенных задач',
'No task for this project' => 'Нет задач для этого проекта',
@@ -175,16 +166,10 @@ return array(
'Complexity' => 'Сложность',
'Task limit' => 'Лимит задач',
'Task count' => 'Количество задач',
- 'Edit project access list' => 'Изменить доступ к проекту',
- 'Allow this user' => 'Разрешить этого пользователя',
- 'Don\'t forget that administrators have access to everything.' => 'Помните, администратор имеет неограниченные права.',
- 'Revoke' => 'Отозвать',
- 'List of authorized users' => 'Список авторизованных пользователей',
'User' => 'Пользователь',
- 'Nobody have access to this project.' => 'Ни у кого нет доступа к этому проекту',
'Comments' => 'Комментарии',
'Write your text in Markdown' => 'Справка по синтаксису Markdown',
- 'Leave a comment' => 'Оставить комментарий 2',
+ 'Leave a comment' => 'Оставить комментарий',
'Comment is required' => 'Нужен комментарий',
'Leave a description' => 'Напишите описание',
'Comment added successfully.' => 'Комментарий успешно добавлен.',
@@ -192,11 +177,8 @@ return array(
'Edit this task' => 'Изменить задачу',
'Due Date' => 'Сделать до',
'Invalid date' => 'Неверная дата',
- 'Must be done before %B %e, %Y' => 'Должно быть сделано до %B %e %Y',
- '%B %e, %Y' => '%B, %e, %Y',
- '%b %e, %Y' => '%b %e, %Y',
'Automatic actions' => 'Автоматические действия',
- 'Your automatic action have been created successfully.' => 'Автоматика успешно настроена.',
+ 'Your automatic action have been created successfully.' => 'Автоматизированное действие успешно настроено.',
'Unable to create your automatic action.' => 'Не удалось создать автоматизированное действие.',
'Remove an action' => 'Удалить действие',
'Unable to remove this action.' => 'Не удалось удалить действие',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Назначить определенный цвет пользователю',
'Column title' => 'Название колонки',
'Position' => 'Расположение',
- 'Move Up' => 'Сдвинуть вверх',
- 'Move Down' => 'Сдвинуть вниз',
'Duplicate to another project' => 'Клонировать в другой проект',
'Duplicate' => 'Клонировать',
'link' => 'ссылка',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Комментарий удален.',
'Unable to remove this comment.' => 'Не удалось удалить этот комментарий.',
'Do you really want to remove this comment?' => 'Вы точно хотите удалить этот комментарий?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Только администратор и автор комментария имеют доступ к этой странице.',
'Current password for the user "%s"' => 'Текущий пароль для пользователя « %s »',
'The current password is required' => 'Требуется текущий пароль',
'Wrong password' => 'Неверный пароль',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'Внешняя авторизация не удалась',
'Your external account is linked to your profile successfully.' => 'Ваш внешний аккаунт успешно подключен к профилю.',
'Email' => 'E-mail',
- 'Link my Google Account' => 'Привязать мой профиль к Google',
- 'Unlink my Google Account' => 'Отвязать мой профиль от Google',
- 'Login with my Google Account' => 'Аутентификация через Google',
- 'Project not found.' => 'Проект не найден.',
'Task removed successfully.' => 'Задача удалена.',
'Unable to remove this task.' => 'Не удалось удалить эту задачу.',
'Remove a task' => 'Удалить задачу',
@@ -301,7 +276,7 @@ return array(
'File removed successfully.' => 'Файл удален.',
'Attach a document' => 'Прикрепить файл',
'Do you really want to remove this file: "%s"?' => 'Вы точно хотите удалить этот файл « %s » ?',
- 'Attachments' => 'Приложение',
+ 'Attachments' => 'Вложения',
'Edit the task' => 'Изменить задачу',
'Edit the description' => 'Изменить описание',
'Add a comment' => 'Добавить комментарий',
@@ -317,7 +292,7 @@ return array(
'estimated' => 'расчетное',
'Sub-Tasks' => 'Подзадачи',
'Add a sub-task' => 'Добавить подзадачу',
- 'Original estimate' => 'Запланировано',
+ 'Original estimate' => 'Заплан.',
'Create another sub-task' => 'Создать другую подзадачу',
'Time spent' => 'Времени затрачено',
'Edit a sub-task' => 'Изменить подзадачу',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Максимальный размер: ',
'Unable to upload the file.' => 'Не удалось загрузить файл.',
'Display another project' => 'Показать другой проект',
- 'Login with my Github Account' => 'Аутентификация через Github',
- 'Link my Github Account' => 'Привязать мой профиль к Github',
- 'Unlink my Github Account' => 'Отвязать мой профиль от Github',
'Created by %s' => 'Создано %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Последнее изменение %d/%m/%Y в %H:%M',
'Tasks Export' => 'Экспорт задач',
'Tasks exportation for "%s"' => 'Задача экспортирована для « %s »',
'Start Date' => 'Дата начала',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Я хочу получать уведомления только по этим проектам:',
'view the task on Kanboard' => 'посмотреть задачу на Kanboard',
'Public access' => 'Общий доступ',
- 'User management' => 'Управление пользователями',
'Active tasks' => 'Активные задачи',
'Disable public access' => 'Отключить общий доступ',
'Enable public access' => 'Включить общий доступ',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'E-mail:',
'Notifications:' => 'Уведомления:',
'Notifications' => 'Уведомления',
- 'Group:' => 'Группа:',
- 'Regular user' => 'Обычный пользователь',
'Account type:' => 'Тип профиля:',
'Edit profile' => 'Редактировать профиль',
'Change password' => 'Сменить пароль',
'Password modification' => 'Изменение пароля',
'External authentications' => 'Внешняя аутентификация',
- 'Google Account' => 'Профиль Google',
- 'Github Account' => 'Профиль Github',
'Never connected.' => 'Ранее не соединялось.',
- 'No account linked.' => 'Нет связанных профилей.',
- 'Account linked.' => 'Профиль связан.',
'No external authentication enabled.' => 'Нет активной внешней аутентификации.',
'Password modified successfully.' => 'Пароль изменен.',
'Unable to change the password.' => 'Не удалось сменить пароль.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s сменил назначенного для задачи %s на %s',
'New password for the user "%s"' => 'Новый пароль для пользователя "%s"',
'Choose an event' => 'Выберите событие',
- 'Github commit received' => 'Github: коммит получен',
- 'Github issue opened' => 'Github: новая проблема',
- 'Github issue closed' => 'Github: проблема закрыта',
- 'Github issue reopened' => 'Github: проблема переоткрыта',
- 'Github issue assignee change' => 'Github: сменить ответственного за проблему',
- 'Github issue label change' => 'Github: ярлык проблемы изменен',
'Create a task from an external provider' => 'Создать задачу из внешнего источника',
'Change the assignee based on an external username' => 'Изменить назначенного основываясь на внешнем имени пользователя',
'Change the category based on an external label' => 'Изменить категорию основываясь на внешнем ярлыке',
'Reference' => 'Ссылка',
- 'Reference: %s' => 'Ссылка: %s',
'Label' => 'Ярлык',
'Database' => 'База данных',
'About' => 'Информация',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Частота в секундах (60 секунд по умолчанию)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Частота в секундах (0 для выключения, 10 секунд по умолчанию)',
'Application URL' => 'URL приложения',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Пример: http://example.kanboard.net (используется в email уведомлениях)',
'Token regenerated.' => 'Токен пересоздан',
'Date format' => 'Формат даты',
'ISO format is always accepted, example: "%s" and "%s"' => 'Время должно быть в ISO-формате, например: "%s" или "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Это проект с ограниченным доступом',
'Type here to create a new sub-task' => 'Печатайте сюда чтобы создать подзадачу',
'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.' => 'Вам ничего не назначено',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Любой может получить доступ к этому проекту.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github webhooks',
- 'Help on Github webhooks' => 'Помощь по Github webhooks',
'Create a comment from an external provider' => 'Создать комментарий из внешнего источника',
- 'Github issue comment created' => 'Github issue комментарий создан',
'Project management' => 'Управление проектом',
'My projects' => 'Мои проекты',
'Columns' => 'Колонки',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Перераспределение пользователей для "%s"',
'Clone this project' => 'Клонировать проект',
'Column removed successfully.' => 'Колонка успешно удалена.',
- 'Github Issue' => 'Вопрос на Github',
'Not enough data to show the graph.' => 'Недостаточно данных, чтобы показать график.',
'Previous' => 'Предыдущий',
'The id must be an integer' => 'Этот id должен быть целочисленным',
@@ -541,16 +490,13 @@ return array(
'Nothing to preview...' => 'Нет данных для предпросмотра...',
'Preview' => 'Предпросмотр',
'Write' => 'Написание',
- 'Active swimlanes' => 'Активные ',
+ '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.' => 'Дорожка не найдена.',
@@ -558,24 +504,13 @@ return array(
'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 вопрос открыт',
- '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 вопросы',
'Subtask Id' => 'Id подзадачи',
'Subtasks' => 'Подзадачи',
'Subtasks Export' => 'Экспортировать подзадачи',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'У вас уже есть одна задача в разработке',
'Which parts of the project do you want to duplicate?' => 'Какие части проекта должны быть дублированы?',
'Disallow login form' => 'Запретить форму входа',
- // 'Bitbucket commit received' => '',
- 'Bitbucket webhooks' => 'BitBucket webhooks',
- 'Help on Bitbucket webhooks' => 'Помощь по BitBucket webhooks',
'Start' => 'Начало',
'End' => 'Конец',
'Task age in days' => 'Возраст задачи в днях',
@@ -628,7 +560,7 @@ return array(
'Task\'s links' => 'Ссылки задачи',
'The labels must be different' => 'Ярлыки должны быть разными',
'There is no link.' => 'Это не ссылка',
- 'This label must be unique' => 'Этот ярлык должна быть уникальной ',
+ 'This label must be unique' => 'Этот ярлык должен быть уникальным ',
'Unable to create your link.' => 'Не удается создать эту ссылку.',
'Unable to update your link.' => 'Не удается обновить эту ссылку.',
'Unable to remove this link.' => 'Не удается удалить эту ссылку.',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Эта задача',
'<1h' => '<1ч',
'%dh' => '%dh',
- '%b %e' => '%b %e',
'Expand tasks' => 'Развернуть задачи',
'Collapse tasks' => 'Свернуть задачи',
'Expand/collapse tasks' => 'Развернуть/свернуть задачи',
@@ -656,18 +587,15 @@ return array(
'Keyboard shortcuts' => 'Горячие клавиши',
'Open board switcher' => 'Открыть переключатель доски',
'Application' => 'Приложение',
- // 'since %B %e, %Y at %k:%M %p' => '',
'Compact view' => 'Компактный вид',
'Horizontal scrolling' => 'Широкий вид',
'Compact/wide view' => 'Компактный/широкий вид',
'No results match:' => 'Отсутствуют результаты:',
'Currency' => 'Валюта',
- 'Files' => 'Файлы',
- 'Images' => 'Изображения',
'Private project' => 'Приватный проект',
'AUD - Australian Dollar' => 'AUD - Австралийский доллар',
'CAD - Canadian Dollar' => 'CAD - Канадский доллар',
- 'CHF - Swiss Francs' => 'CHF - Швейцарский Франк',
+ 'CHF - Swiss Francs' => 'CHF - Швейцарский франк',
'Custom Stylesheet' => 'Пользовательский стиль',
'download' => 'загрузить',
'EUR - Euro' => 'EUR - Евро',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'Код двухфакторной авторизации валиден',
'Code' => 'Код',
'Two factor authentication' => 'Двухфакторная авторизация',
- 'Enable/disable two factor authentication' => 'Включить/выключить двухфакторную авторизацию',
'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' => 'загружено: %s',
- 'uploaded on: %s' => 'загружено в: %s',
- 'size: %s' => 'размер: %s',
'Burndown chart for "%s"' => 'Диаграмма сгорания для « %s »',
'Burndown chart' => 'Диаграмма сгорания',
'This chart show the task complexity over the time (Work Remaining).' => 'Эта диаграмма показывают сложность задачи по времени (оставшейся работы).',
@@ -722,7 +645,6 @@ return array(
'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' => 'Идентификатор',
'Disable two factor authentication' => 'Выключить двухфакторную авторизацию',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Вы действительно хотите выключить двухфакторную авторизацию для пользователя "%s"?',
@@ -731,7 +653,6 @@ return array(
'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 звязанной задачи не существует',
@@ -769,7 +690,7 @@ return array(
'Automatically update the start date' => 'Автоматическое обновление даты начала',
'iCal feed' => 'iCal данные',
'Preferences' => 'Предпочтения',
- 'Security' => 'Безопастность',
+ 'Security' => 'Безопасность',
'Two factor authentication disabled' => 'Двухфакторная аутентификация отключена',
'Two factor authentication enabled' => 'Включена двухфакторная аутентификация',
'Unable to update this user.' => 'Не удается обновить этого пользователя.',
@@ -777,43 +698,32 @@ return array(
'User that will receive the email' => 'Пользователь, который будет получать e-mail',
'Email subject' => 'Тема e-mail',
'Date' => 'Дата',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
- // 'Add a comment log when moving the task between columns' => '',
- // 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
- // 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
+ 'Add a comment log when moving the task between columns' => 'Добавлять запись при перемещении задачи между колонками',
+ 'Move the task to another column when the category is changed' => 'Переносить задачи в другую колонку при изменении категории',
+ 'Send a task by email to someone' => 'Отправить задачу по email',
+ 'Reopen a task' => 'Переоткрыть задачу',
'Column change' => 'Изменение колонки',
'Position change' => 'Позиция изменена',
'Swimlane change' => 'Дорожка изменена',
- // 'Assignee change' => '',
+ 'Assignee change' => 'Назначенный пользователь изменен',
'[%s] Overdue tasks' => '[%s] просроченные задачи',
'Notification' => 'Уведомления',
'%s moved the task #%d to the first swimlane' => '%s задач перемещено #%d в первой дорожке',
'%s moved the task #%d to the swimlane "%s"' => '%s задач перемещено #%d в дорожке "%s"',
'Swimlane' => 'Дорожки',
'Gravatar' => 'Граватар',
- // '%s moved the task %s to the first swimlane' => '',
- // '%s moved the task %s to the swimlane "%s"' => '',
+ '%s moved the task %s to the first swimlane' => '%s переместил задачу %s на первую дорожку',
+ '%s moved the task %s to the swimlane "%s"' => '%s переместил задачу %s на дорожку "%s"',
'This report contains all subtasks information for the given date range.' => 'Этот отчет содержит всю информацию подзадач в заданном диапазоне дат.',
'This report contains all tasks information for the given date range.' => 'Этот отчет содержит всю информацию для задачи в заданном диапазоне дат.',
'Project activities for %s' => 'Активность проекта для %s',
- // 'view the board on Kanboard' => '',
+ 'view the board on Kanboard' => 'посмотреть доску на Kanboard',
'The task have been moved to the first swimlane' => 'Эта задача была перемещена в первую дорожку',
'The task have been moved to another swimlane:' => 'Эта задача была перемещена в другую дорожку:',
'Overdue tasks for the project "%s"' => 'Просроченные задачи для проекта "%s"',
'New title: %s' => 'Новый заголовок: %s',
'The task is not assigned anymore' => 'Задача больше не назначена',
- // 'New assignee: %s' => '',
+ 'New assignee: %s' => 'Новый назначенный: %s',
'There is no category now' => 'В настоящее время здесь нет категорий',
'New category: %s' => 'Новая категория: %s',
'New color: %s' => 'Новый цвет: %s',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'Поле "%s" ,было изменено',
'The description have been modified' => 'Описание было изменено',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Вы действительно хотите закрыть задачу "%s", а также все подзадачи?',
- 'Swimlane: %s' => 'Дорожка: %s',
'I want to receive notifications for:' => 'Я хочу получать уведомления для:',
'All tasks' => 'Все задачи',
'Only for tasks assigned to me' => 'Только для задач, назначенных на меня',
'Only for tasks created by me' => 'Только для задач, созданных мной',
'Only for tasks created by me and assigned to me' => 'Только для задач, созданных мной и назначенных мной',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%b %e, %Y, %k:%M %p',
- 'New due date: %B %e, %Y' => 'Новая дата завершения: %B %e, %Y',
- 'Start date changed: %B %e, %Y' => 'Изменить дату начала: %B %e, %Y',
- '%k:%M %p' => '%k:%M %p',
'%%Y-%%m-%%d' => '%%Y-%%m-%%d',
'Total for all columns' => 'Суммарно для всех колонок',
'You need at least 2 days of data to show the chart.' => 'Для отображения диаграммы нужно по крайней мере 2 дня.',
@@ -845,7 +749,7 @@ return array(
'Stop timer' => 'Остановить таймер',
'Start timer' => 'Запустить таймер',
'Add project member' => 'Добавить номер проекта',
- 'Enable notifications' => 'Отключить уведомления',
+ 'Enable notifications' => 'Включить уведомления',
'My activity stream' => 'Лента моей активности',
'My calendar' => 'Мой календарь',
'Search tasks' => 'Поиск задачи',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Не назначенные',
'View advanced search syntax' => 'Просмотр расширенного синтаксиса поиска',
'Overview' => 'Обзор',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Просмотр Доска/Календарь/Список',
'Switch to the board view' => 'Переключиться в режим доски',
'Switch to the calendar view' => 'Переключиться в режим календаря',
@@ -887,37 +790,27 @@ return array(
'Average time spent' => 'Затрачено времени в среднем',
'This chart show the average time spent into each column for the last %d tasks.' => 'Эта диаграмма показывает среднее время, проведенное задачами в каждой колонке за последний %d.',
'Average Lead and Cycle time' => 'Среднее время выполнения и цикла',
- 'Average lead time: ' => 'Среднее время выполнения',
- 'Average cycle time: ' => 'Среднее время цикла',
+ 'Average lead time: ' => 'Среднее время выполнения: ',
+ 'Average cycle time: ' => 'Среднее время цикла: ',
'Cycle Time' => 'Время цикла',
'Lead Time' => 'Время выполнения',
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Эта диаграма показывает среднее время выполнения и цикла задачь в последние %d.',
'Average time into each column' => 'Среднее время в каждом столбце',
'Lead and cycle time' => 'Время выполнения и цикла',
- 'Google Authentication' => 'Авторизация Google',
- 'Help on Google authentication' => 'Помощь в авторизации Google',
- 'Github Authentication' => 'Авторизация Github',
- 'Help on Github authentication' => 'Помощь в авторизации Github',
'Lead time: ' => 'Время выполнения:',
'Cycle time: ' => 'Время цикла:',
'Time spent into each column' => 'Время, проведенное в каждой колонке',
'The lead time is the duration between the task creation and the completion.' => 'Время выполнения - период между созданием задачи и завершения.',
'The cycle time is the duration between the start date and the completion.' => 'Время цикла - период времени между датой начала и завершения.',
- // 'If the task is not closed the current time is used instead of the completion date.' => '',
+ 'If the task is not closed the current time is used instead of the completion date.' => 'Если задача не закрыта, то текущая дата будет указана в дате завершения задачи.',
'Set automatically the start date' => 'Установить автоматическую дату начала',
'Edit Authentication' => 'Редактировать авторизацию',
- 'Google Id' => 'Google I',
- 'Github Id' => 'Github Id',
'Remote user' => 'Удаленный пользователь',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Учетные данные для входа через LDAP, Google и Github не будут сохранены в Kanboard.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Если вы установите флажок "Запретить форму входа", учетные данные, введенные в форму входа будет игнорироваться.',
- 'By @%s on Gitlab' => 'От @%s на Gitlab',
- 'Gitlab issue comment created' => 'Был создан комментарий к задаче на Gitlab',
'New remote user' => 'Новый удаленный пользователь',
'New local user' => 'Новый локальный пользователь',
'Default task color' => 'Стандартные цвета задач',
- 'Hide sidebar' => 'Свернуть сайдбар',
- 'Expand sidebar' => 'Показать сайдбар',
'This feature does not work with all browsers.' => 'Эта функция доступна не во всех браузерах.',
'There is no destination project available.' => 'Нет доступного для назначения проекта.',
'Trigger automatically subtask time tracking' => 'Триггер автоматического отслеживания времени подзадач',
@@ -926,15 +819,14 @@ return array(
'Current column: %s' => 'Текущая колонка: %s',
'Current category: %s' => 'Текущая категория: %s',
'no category' => 'без категории',
- 'Current assignee: %s' => 'Current assignee: %s',
+ 'Current assignee: %s' => 'Текущее назначенное лицо: %s',
'not assigned' => 'не назначен',
'Author:' => 'Автор:',
'contributors' => 'соавторы',
'License:' => 'Лицензия:',
'License' => 'Лицензия',
- 'Project Administrator' => 'Администратор проекта',
'Enter the text below' => 'Введите текст ниже',
- 'Gantt chart for %s' => 'Диаграмма Гантта для %s',
+ 'Gantt chart for %s' => 'Диаграмма Ганта для %s',
'Sort by position' => 'Сортировать по позиции',
'Sort by date' => 'Сортировать по дате',
'Add task' => 'Добавить задачу',
@@ -943,7 +835,7 @@ return array(
'There is no start date or due date for this task.' => 'Для этой задачи нет даты начала или завершения.',
'Moving or resizing a task will change the start and due date of the task.' => 'Изменение или перемещение задачи повлечет изменение даты начала завершения задачи.',
'There is no task in your project.' => 'В Вашем проекте задач нет.',
- 'Gantt chart' => 'Диаграмма Гантта',
+ 'Gantt chart' => 'Диаграмма Ганта',
'People who are project managers' => 'Люди, которые менеджеры проекта',
'People who are project members' => 'Люди, которые участники проекта',
'NOK - Norwegian Krone' => 'НК - Норвежская крона',
@@ -952,116 +844,308 @@ return array(
'open file' => 'открыть файл',
'End date' => 'Дата завершения',
'Users overview' => 'Обзор пользователей',
- 'Managers' => 'Менеджеры',
'Members' => 'Участники',
'Shared project' => 'Общие/публичные проекты',
'Project managers' => 'Менеджер проекта',
- 'Project members' => 'Участники проекта',
- 'Gantt chart for all projects' => 'Диаграмма Гантта для всех проектов',
+ 'Gantt chart for all projects' => 'Диаграмма Ганта для всех проектов',
'Projects list' => 'Список проектов',
- 'Gantt chart for this project' => 'Диаграмма Гантта для этого проекта',
+ 'Gantt chart for this project' => 'Диаграмма Ганта для этого проекта',
'Project board' => 'Доска проекта',
'End date:' => 'Дата завершения:',
'There is no start date or end date for this project.' => 'В проекте не указаны дата начала или завершения.',
- 'Projects Gantt chart' => 'Диаграмма Гантта проектов',
- 'Start date: %s' => 'Дата начала: %s',
- 'End date: %s' => 'Дата завершения: %s',
+ 'Projects Gantt chart' => 'Диаграмма Ганта проектов',
'Link type' => 'Тип ссылки',
'Change task color when using a specific task link' => 'Изменение цвета задач при использовании ссылки на определенные задачи',
'Task link creation or modification' => 'Ссылка на создание или модификацию задачи',
- 'Login with my Gitlab Account' => 'Авторизоваться через аккаунт Gitlab',
'Milestone' => 'Веха',
- 'Gitlab Authentication' => 'Авторизация через Gitlab',
- 'Help on Gitlab authentication' => 'Помощь а авторизации через Gitlab',
- 'Gitlab Id' => 'Gitlab Id',
- 'Gitlab Account' => 'Аккаунт Gitlab',
- 'Link my Gitlab Account' => 'Привязать аккаунт Gitlab',
- 'Unlink my Gitlab Account' => 'Отвязать аккаунт Gitlab',
'Documentation: %s' => 'Документация: %s',
- 'Switch to the Gantt chart view' => 'Переключиться в режим диаграммы Гантта',
+ 'Switch to the Gantt chart view' => 'Переключиться в режим диаграммы Ганта',
'Reset the search/filter box' => 'Сбросить поиск/фильтр',
'Documentation' => 'Документация',
- 'Table of contents' => 'Сожержание',
- 'Gantt' => 'Гантт',
- 'Help with project permissions' => 'Помощь с правами доступа по проекту',
- // 'Author' => '',
- // 'Version' => '',
- // 'Plugins' => '',
- // 'There is no plugin loaded.' => '',
- // 'Set maximum column height' => '',
- // 'Remove maximum column height' => '',
- // 'My notifications' => '',
- // 'Custom filters' => '',
- // 'Your custom filter have been created successfully.' => '',
- // 'Unable to create your custom filter.' => '',
- // 'Custom filter removed successfully.' => '',
- // 'Unable to remove this custom filter.' => '',
- // 'Edit custom filter' => '',
- // 'Your custom filter have been updated successfully.' => '',
- // 'Unable to update custom filter.' => '',
- // 'Web' => '',
- // 'New attachment on task #%d: %s' => '',
- // 'New comment on task #%d' => '',
- // 'Comment updated on task #%d' => '',
- // 'New subtask on task #%d' => '',
- // 'Subtask updated on task #%d' => '',
- // 'New task #%d: %s' => '',
- // 'Task updated #%d' => '',
- // 'Task #%d closed' => '',
- // 'Task #%d opened' => '',
- // 'Column changed for task #%d' => '',
- // 'New position for task #%d' => '',
- // 'Swimlane changed for task #%d' => '',
- // 'Assignee changed on task #%d' => '',
- // '%d overdue tasks' => '',
- // 'Task #%d is overdue' => '',
- // 'No new notifications.' => '',
- // 'Mark all as read' => '',
- // 'Mark as read' => '',
- // 'Total number of tasks in this column across all swimlanes' => '',
- // 'Collapse swimlane' => '',
- // 'Expand swimlane' => '',
- // 'Add a new filter' => '',
- // 'Share with all project members' => '',
- // 'Shared' => '',
- // 'Owner' => '',
- // 'Unread notifications' => '',
- // 'My filters' => '',
- // 'Notification methods:' => '',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'Table of contents' => 'Содержание',
+ 'Gantt' => 'Гант',
+ 'Author' => 'Автор',
+ 'Version' => 'Версия',
+ 'Plugins' => 'Плагины',
+ 'There is no plugin loaded.' => 'Нет установленных плагинов.',
+ 'Set maximum column height' => 'Установить максимальную высоту колонки',
+ 'Remove maximum column height' => 'Сбросить максимальную высоту колонки',
+ 'My notifications' => 'Мои уведомления',
+ 'Custom filters' => 'Пользовательские фильтры',
+ 'Your custom filter have been created successfully.' => 'Фильтр был успешно создан.',
+ 'Unable to create your custom filter.' => 'Невозможно создать фильтр.',
+ 'Custom filter removed successfully.' => 'Пользовательский фильтр был успешно удален.',
+ 'Unable to remove this custom filter.' => 'Невозможно удалить фильтр.',
+ 'Edit custom filter' => 'Изменить пользовательский фильтр',
+ 'Your custom filter have been updated successfully.' => 'Пользовательский фильтр был успешно обновлен.',
+ 'Unable to update custom filter.' => 'Невозможно обновить фильтр.',
+ 'Web' => 'Интернет',
+ 'New attachment on task #%d: %s' => 'Новое вложение для задачи #%d: %s',
+ 'New comment on task #%d' => 'Новый комментарий для задачи #%d',
+ 'Comment updated on task #%d' => 'Обновлен комментарий у задачи #%d',
+ 'New subtask on task #%d' => 'Новая подзадача у задачи #%d',
+ 'Subtask updated on task #%d' => 'Подзадача обновлена у задачи #%d',
+ 'New task #%d: %s' => 'Новая задача #%d: %s',
+ 'Task updated #%d' => 'Обновлена задача #%d',
+ 'Task #%d closed' => 'Задача #%d закрыта',
+ 'Task #%d opened' => 'Задача #%d открыта',
+ 'Column changed for task #%d' => 'Обновлена колонка у задачи #%d',
+ 'New position for task #%d' => 'Новая позиция для задачи #%d',
+ 'Swimlane changed for task #%d' => 'Изменена дорожка у задачи #%d',
+ 'Assignee changed on task #%d' => 'Изменен назначенный у задачи #%d',
+ '%d overdue tasks' => '%d просроченных задач',
+ 'Task #%d is overdue' => 'Задача #%d просрочена',
+ 'No new notifications.' => 'Нет новых уведомлений.',
+ 'Mark all as read' => 'Пометить все прочитанными',
+ 'Mark as read' => 'Пометить прочитанным',
+ 'Total number of tasks in this column across all swimlanes' => 'Общее число задач в этой колонке на всех дорожках',
+ 'Collapse swimlane' => 'Свернуть дорожку',
+ 'Expand swimlane' => 'Развернуть дорожку',
+ 'Add a new filter' => 'Добавить новый фильтр',
+ 'Share with all project members' => 'Сделать общим для всех участников проекта',
+ 'Shared' => 'Общие',
+ 'Owner' => 'Владелец',
+ 'Unread notifications' => 'Непрочитанные уведомления',
+ 'My filters' => 'Мои фильтры',
+ 'Notification methods:' => 'Способы уведомления:',
+ 'Import tasks from CSV file' => 'Импорт задач из CSV-файла',
+ 'Unable to read your file' => 'Невозможно прочитать файл',
+ '%d task(s) have been imported successfully.' => '%d задач было успешно импортировано.',
+ 'Nothing have been imported!' => 'Ничего не было импортировано!',
+ 'Import users from CSV file' => 'Импорт пользователей из CSV-файла',
+ '%d user(s) have been imported successfully.' => '%d пользователей было успешно импортировано.',
+ 'Comma' => 'Запятая',
+ 'Semi-colon' => 'Точка с запятой',
+ 'Tab' => 'Пробел (Tab)',
+ 'Vertical bar' => 'Вертикальная черта (|)',
+ 'Double Quote' => 'Одинарные кавычки',
+ 'Single Quote' => 'Двойные кавычки',
+ '%s attached a file to the task #%d' => '%s добавил файл к задаче #%d',
+ 'There is no column or swimlane activated in your project!' => 'В вашей задаче нет активных колонок или дорожек!',
+ 'Append filter (instead of replacement)' => 'Добавляющий фильтр (не заменяющий)',
+ 'Append/Replace' => 'Добавление/Замена',
+ 'Append' => 'Добавление',
+ 'Replace' => 'Замена',
+ 'Import' => 'Импорт',
+ 'change sorting' => 'изменить сортировку',
+ 'Tasks Importation' => 'Импортирование задач',
+ 'Delimiter' => 'Разделитель',
+ 'Enclosure' => 'Тип кавычек',
+ 'CSV File' => 'CSV-файл',
+ 'Instructions' => 'Инструкции',
+ 'Your file must use the predefined CSV format' => 'Ваш файл должен использовать структуру формата CSV',
+ 'Your file must be encoded in UTF-8' => 'Ваш файл должен иметь кодировку UTF-8',
+ 'The first row must be the header' => 'В первой строке должны быть заголовки столбцов',
+ 'Duplicates are not verified for you' => 'Проверка на дубликаты не осуществляется',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'Дата просрочки должна быть в формате ISO: ГГГГ-ММ-ДД',
+ 'Download CSV template' => 'Скачать шаблон CSV-файла',
+ 'No external integration registered.' => 'Нет зарегистрированных внешних интеграций.',
+ 'Duplicates are not imported' => 'Дубликаты не импортируются',
+ 'Usernames must be lowercase and unique' => 'Логины пользователей должны быть строчными и уникальными',
+ 'Passwords will be encrypted if present' => 'Пароли будут зашифрованы (если указаны)',
+ '%s attached a new file to the task %s' => '%s добавил новый файл к задаче %s',
+ 'Assign automatically a category based on a link' => 'Автоматически назначать категории на основе ссылки',
+ 'BAM - Konvertible Mark' => 'BAM - Конвертируемая марка',
+ 'Assignee Username' => 'Логин назначенного',
+ 'Assignee Name' => 'Имя назначенного',
+ 'Groups' => 'Группы',
+ 'Members of %s' => 'Участник группы %s',
+ 'New group' => 'Новая группа',
+ 'Group created successfully.' => 'Группа успешно создана.',
+ 'Unable to create your group.' => 'Невозможно создать группу.',
+ 'Edit group' => 'Именить группу',
+ 'Group updated successfully.' => 'Группы успешно обновлена.',
+ 'Unable to update your group.' => 'Невозможно обновить группу.',
+ 'Add group member to "%s"' => 'Добавить участника в "%s"',
+ 'Group member added successfully.' => 'Участник группы успешно добавлен.',
+ 'Unable to add group member.' => 'Невозможно добавить участника.',
+ 'Remove user from group "%s"' => 'Удалить пользователя из группы "%s"',
+ 'User removed successfully from this group.' => 'Пользователь успешно удален из группы.',
+ 'Unable to remove this user from the group.' => 'Невозможно удалить пользователя из группы.',
+ 'Remove group' => 'Удалить группу',
+ 'Group removed successfully.' => 'Группа успешно удалена.',
+ 'Unable to remove this group.' => 'Невозможно удалить группу.',
+ 'Project Permissions' => 'Разрешения проекта',
+ 'Manager' => 'Менеджер',
+ 'Project Manager' => 'Менеджер проекта',
+ 'Project Member' => 'Участник проекта',
+ 'Project Viewer' => 'Наблюдатель проекта',
+ 'Your account is locked for %d minutes' => 'Ваш аккаунт заблокирован на %d минут',
+ 'Invalid captcha' => 'Неверный код подтверждения',
+ 'The name must be unique' => 'Имя должно быть уникальным',
+ 'View all groups' => 'Просмотр всех групп',
+ 'View group members' => 'Просмотр участников группы',
+ 'There is no user available.' => 'Нет доступных пользователей.',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'Вы действительно хотите удалить пользователя "%s" из группы "%s"?',
+ 'There is no group.' => 'Нет созданных групп.',
+ 'External Id' => 'Внешний Id',
+ 'Add group member' => 'Добавить участника в группу',
+ 'Do you really want to remove this group: "%s"?' => 'Вы действительно хотите удалить группу "%s"?',
+ 'There is no user in this group.' => 'В этой группе нет участников.',
+ 'Remove this user' => 'Удалить пользователя.',
+ 'Permissions' => 'Разрешения',
+ 'Allowed Users' => 'Разрешенные пользователи',
+ 'No user have been allowed specifically.' => 'Нет заданных разрешений для пользователей.',
+ 'Role' => 'Роль',
+ 'Enter user name...' => 'Введите имя пользователя...',
+ 'Allowed Groups' => 'Разрешенные группы',
+ 'No group have been allowed specifically.' => 'Нет заданных разрешений для групп.',
+ 'Group' => 'Группа',
+ 'Group Name' => 'Имя группы',
+ 'Enter group name...' => 'Введите имя группы...',
+ 'Role:' => 'Роль:',
+ 'Project members' => 'Участники проекта',
+ 'Compare hours for "%s"' => 'Сравнить часы для "%s"',
+ '%s mentioned you in the task #%d' => '%s упомянул вас в задаче #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s упомянул вас в комментарии к задаче #%d',
+ 'You were mentioned in the task #%d' => 'Вы упомянуты в задаче #%d',
+ 'You were mentioned in a comment on the task #%d' => 'Вы упомянуты в комментарии к задаче #%d',
+ 'Mentioned' => 'Упоминания',
+ 'Compare Estimated Time vs Actual Time' => 'Сравнить запланированное время и реальное',
+ 'Estimated hours: ' => 'Запланировано часов: ',
+ 'Actual hours: ' => 'Реально затрачено часов: ',
+ 'Hours Spent' => 'Затрачено часов',
+ 'Hours Estimated' => 'Запланировано часов',
+ 'Estimated Time' => 'Запланировано времени',
+ 'Actual Time' => 'Затрачено времени',
+ 'Estimated vs actual time' => 'Запланировано и реально затрачено времени',
+ 'RUB - Russian Ruble' => 'Руб - Российский рубль',
+ 'Assign the task to the person who does the action when the column is changed' => 'Назначить задачу пользователю, который произвел изменение в колонке',
+ 'Close a task in a specific column' => 'Закрыть задачу в выбранной колонке',
+ 'Time-based One-time Password Algorithm' => 'Зависимый от времени, одноразовый алгоритм пароля',
+ 'Two-Factor Provider: ' => 'Провайдер двух-факторной авторизации: ',
+ 'Disable two-factor authentication' => 'Отключить двух-факторную авторизацию',
+ 'Enable two-factor authentication' => 'Включить двух-факторную авторизацию',
+ 'There is no integration registered at the moment.' => 'Интеграции в данный момент не зарегистрированы.',
+ 'Password Reset for Kanboard' => 'Сброс пароля для Kanboard',
+ 'Forgot password?' => 'Забыли пароль?',
+ 'Enable "Forget Password"' => 'Включить возможность восстановления пароля',
+ 'Password Reset' => 'Сброс пароля',
+ 'New password' => 'Новый пароль',
+ 'Change Password' => 'Изменить пароль',
+ 'To reset your password click on this link:' => 'Чтобы изменить пароль нажмите на эту ссылку:',
+ 'Last Password Reset' => 'Последний сброс пароля',
+ 'The password has never been reinitialized.' => 'Пароль никогда не был сброшен.',
+ 'Creation' => 'Создан',
+ 'Expiration' => 'Истекает',
+ 'Password reset history' => 'История сброса пароля',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Все задачи для колонки "%s" и дорожки "%s" были успешно закрыты.',
+ 'Do you really want to close all tasks of this column?' => 'Вы действительно хотите закрыть все задачи из этой колонки?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d задач в колонке "%s" и дорожке "%s" будут закрыты.',
+ 'Close all tasks of this column' => 'Закрыть все задачи в этой колонке',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Нет плагинов уведомлений проекта. Вы можете настроить индивидуальные уведомления в вашем профиле пользователя.',
+ 'My dashboard' => 'Мой кабинет',
+ 'My profile' => 'Мой профиль',
+ 'Project owner: ' => 'Владелец проекта:',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'Идентификатор проекта не обязателен и должен содержать буквенно-цифровые символы, пример: MYPROJECT',
+ 'Project owner' => 'Владелец проекта',
+ 'Those dates are useful for the project Gantt chart.' => 'Эти даты используются для диаграммы Ганта проекта.',
+ 'Private projects do not have users and groups management.' => 'Приватные проекты не имеют управления пользователями и группами.',
+ 'There is no project member.' => 'Нет участников проекта.',
+ 'Priority' => 'Приоритет',
+ 'Task priority' => 'Приоритет задачи',
+ 'General' => 'Общее',
+ 'Dates' => 'Даты',
+ 'Default priority' => 'Приоритет по-умолчанию',
+ 'Lowest priority' => 'Наименьший приоритет',
+ 'Highest priority' => 'Наивысший приоритет',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'Если Вы введете 0 для наименьшего и наивысшего приоритета, этот функционал будет отключен.',
+ 'Close a task when there is no activity' => 'Закрывать задачу, когда нет активности',
+ 'Duration in days' => 'Длительность в днях',
+ 'Send email when there is no activity on a task' => 'Отправлять email, когда активность по задаче отсутствует',
+ 'List of external links' => 'Список внешних ссылок',
+ 'Unable to fetch link information.' => 'Не удалось получить информацию о ссылке',
+ 'Daily background job for tasks' => 'Ежедневные фоновые работы для задач',
+ 'Auto' => 'Авто',
+ 'Related' => 'Связано',
+ 'Attachment' => 'Вложение',
+ 'Title not found' => 'Заголовок не найден',
+ 'Web Link' => 'Web-ссылка',
+ 'External links' => 'Внешние ссылки',
+ 'Add external link' => 'Добавить внешнюю ссылку',
+ 'Type' => 'Тип',
+ 'Dependency' => 'Зависимость',
+ 'Add internal link' => 'Добавить внутреннюю ссылку',
+ 'Add a new external link' => 'Добавить новую внешнюю ссылку',
+ 'Edit external link' => 'Изменить внешнюю ссылку',
+ 'External link' => 'Внешняя ссылка',
+ 'Copy and paste your link here...' => 'Скопируйте и вставьте вашу ссылку здесь',
+ 'URL' => 'URL',
+ 'There is no external link for the moment.' => 'На данный момент внешние ссылки отсутствуют',
+ 'Internal links' => 'Внутренние ссылки',
+ 'There is no internal link for the moment.' => 'На данные момент внутреннии ссылки отсутствуют',
+ 'Assign to me' => 'Связать со мной',
+ 'Me' => 'Мне',
+ 'Do not duplicate anything' => 'Не дублировать ничего',
+ 'Projects management' => 'Управление проектами',
+ 'Users management' => 'Управление пользователями',
+ 'Groups management' => 'Управление группами',
+ 'Create from another project' => 'Создать из другого проекта',
+ 'There is no subtask at the moment.' => 'На данный момент подзадачи отсутствуют',
+ 'open' => 'открыто',
+ 'closed' => 'закрыто',
+ 'Priority:' => 'Приоритет:',
+ 'Reference:' => 'Ссылка:',
+ 'Complexity:' => 'Сложность:',
+ 'Swimlane:' => 'Дорожка:',
+ 'Column:' => 'Столбец:',
+ 'Position:' => 'Позиция:',
+ 'Creator:' => 'Создатель:',
+ 'Time estimated:' => 'Оценочное время:',
+ '%s hours' => '%s часов',
+ 'Time spent:' => 'Времени потрачено:',
+ 'Created:' => 'Создана:',
+ 'Modified:' => 'Изменена:',
+ 'Completed:' => 'Завершена:',
+ 'Started:' => 'Начата:',
+ 'Moved:' => 'Перемещена:',
+ 'Task #%d' => 'Задача #%d',
+ 'Sub-tasks' => 'Подзадачи',
+ 'Date and time format' => 'Формат даты и времени',
+ 'Time format' => 'Формат времени',
+ 'Start date: ' => 'Дата начала:',
+ 'End date: ' => 'Дата окончания:',
+ 'New due date: ' => 'Новая дата исполнения:',
+ 'Start date changed: ' => 'Дата начала изменена:',
+ 'Disable private projects' => 'Выключить приватные проекты',
+ 'Do you really want to remove this custom filter: "%s"?' => 'Вы точно ходите удалить этот пользовательский фильтр: "%s"?',
+ 'Remove a custom filter' => 'Удалить пользовательский фильтр',
+ 'User activated successfully.' => 'Пользователь успешно активирован.',
+ 'Unable to enable this user.' => 'Не удалось включить этого пользователя.',
+ 'User disabled successfully.' => 'Пользователь был успешно выключен.',
+ 'Unable to disable this user.' => 'Не удалось выключить пользователя.',
+ 'All files have been uploaded successfully.' => 'Все файлы были успешно загружены.',
+ 'View uploaded files' => 'Просмотр загруженных файлов',
+ 'The maximum allowed file size is %sB.' => 'Максимально допустимый размер файла: %sB.',
+ 'Choose files again' => 'Выбрать файлы повторно',
+ 'Drag and drop your files here' => 'Переместите ваши файлы сюда',
+ 'choose files' => 'выбор файлов',
+ 'View profile' => 'Просмотр профиля',
+ 'Two Factor' => 'Двухфакторный',
+ 'Disable user' => 'Выключить пользователя',
+ 'Do you really want to disable this user: "%s"?' => 'Вы точно хотите выключить этого пользователя: "%s"?',
+ 'Enable user' => 'Включить пользователя',
+ 'Do you really want to enable this user: "%s"?' => 'Вы точно хотите включить этого пользователя: "%s"?',
+ 'Download' => 'Загрузка',
+ 'Uploaded: %s' => 'Загружено: %s',
+ 'Size: %s' => 'Размер: %s',
+ 'Uploaded by %s' => 'Загружено пользователем: %s',
+ 'Filename' => 'Имя файла',
+ 'Size' => 'Размер',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php
index 0e25033d..3825ecde 100644
--- a/app/Locale/sr_Latn_RS/translations.php
+++ b/app/Locale/sr_Latn_RS/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Ukloni projekat',
'Edit the board for "%s"' => 'Izmeni tablu za "%s"',
'All projects' => 'Svi projekti',
- 'Change columns' => 'Zameni kolonu',
'Add a new column' => 'Dodaj novu kolonu',
'Title' => 'Naslov',
'Nobody assigned' => 'Niko nije dodeljen',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Otvori zadatak',
'Do you really want to open this task: "%s"?' => 'Da li zaista želiš da otvoriš zadatak: "%s"?',
'Back to the board' => 'Nazad na tablu',
- 'Created on %B %e, %Y at %k:%M %p' => 'Kreiran %e %B %Y o %k:%M',
'There is nobody assigned' => 'Niko nije dodeljen!',
'Column on the board:' => 'Kolona na tabli:',
- 'Status is open' => 'Status otvoren',
- 'Status is closed' => 'Status zatvoren',
'Close this task' => 'Zatvori ovaj zadatak',
'Open this task' => 'Otvori ovaj zadatak',
'There is no description.' => 'Bez opisa.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'ID je obavezan',
'The project id is required' => 'ID projekta je obavezan',
'The project name is required' => 'Naziv projekta je obavezan',
- 'This project must be unique' => 'Projekat mora biti jedinstven',
'The title is required' => 'Naslov je obavezan',
'Settings saved successfully.' => 'Podešavanja uspešno snimljena.',
'Unable to save your settings.' => 'Nemoguće snimanje podešavanja.',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'U radu',
'Done' => 'Gotovo',
'Application version:' => 'Verzija aplikacije:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Završeno u %e %B %Y o %k:%M',
- '%B %e, %Y at %k:%M %p' => '%e %B %Y o %k:%M',
- 'Date created' => 'Kreiran dana',
- 'Date completed' => 'Završen dana',
'Id' => 'Id',
'%d closed tasks' => '%d zatvorenih zadataka',
'No task for this project' => 'Nema dodeljenih zadataka ovom projektu',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Složenost',
'Task limit' => 'Ograničenje zadatka',
'Task count' => 'Broj zadataka',
- 'Edit project access list' => 'Izmeni prava pristupa projektu',
- 'Allow this user' => 'Dozvoli ovog korisnika',
- 'Don\'t forget that administrators have access to everything.' => 'Zapamti: Administrator može pristupiti svemu!',
- 'Revoke' => 'Povuci',
- 'List of authorized users' => 'Spisak odobrenih korisnika',
'User' => 'Korisnik',
- 'Nobody have access to this project.' => 'Niko nema pristup ovom projektu',
'Comments' => 'Komentari',
'Write your text in Markdown' => 'Pisanje teksta pomoću Markdown',
'Leave a comment' => 'Ostavi komentar',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Izmeni ovaj zadatak',
'Due Date' => 'Termin',
'Invalid date' => 'Loš datum',
- 'Must be done before %B %e, %Y' => 'Termin do %e %B %Y',
- '%B %e, %Y' => '%e %B %Y',
- // '%b %e, %Y' => '',
'Automatic actions' => 'Automatske akcije',
'Your automatic action have been created successfully.' => 'Uspešno kreirana automatska akcija',
'Unable to create your automatic action.' => 'Nemoguće kreiranje automatske akcije',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Dodeli boju korisniku',
'Column title' => 'Naslov kolone',
'Position' => 'Pozicija',
- 'Move Up' => 'Podigni',
- 'Move Down' => 'Spusti',
'Duplicate to another project' => 'Kopiraj u drugi projekat',
'Duplicate' => 'Napravi kopiju',
'link' => 'link',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Komentar je uspešno obrisan.',
'Unable to remove this comment.' => 'Neuspešno brisanje komentara.',
'Do you really want to remove this comment?' => 'Da li da obrišem ovaj komentar?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Samo administrator i kreator komentara mogu ga obrisati.',
'Current password for the user "%s"' => 'Trenutna lozinka za korisnika "%s"',
'The current password is required' => 'Trenutna lozinka je obavezna',
'Wrong password' => 'Pogrešna lozinka',
@@ -267,10 +246,6 @@ return array(
// 'External authentication failed' => '',
// 'Your external account is linked to your profile successfully.' => '',
'Email' => 'E-mail',
- 'Link my Google Account' => 'Poveži sa Google nalogom',
- 'Unlink my Google Account' => 'Ukini vezu sa Google nalogom',
- 'Login with my Google Account' => 'Prijavi se preko Google naloga',
- 'Project not found.' => 'Projekat nije pronađen.',
'Task removed successfully.' => 'Zadatak uspešno uklonjen.',
'Unable to remove this task.' => 'Nemoguće uklanjanje zadatka.',
'Remove a task' => 'Ukloni zadatak',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maksimalna veličina: ',
'Unable to upload the file.' => 'Nije moguće snimiti fajl.',
'Display another project' => 'Prikaži drugi projekat',
- 'Login with my Github Account' => 'Zaloguj przy użyciu konta Github',
- 'Link my Github Account' => 'Podłącz konto Github',
- 'Unlink my Github Account' => 'Odłącz konto Github',
'Created by %s' => 'Kreirao %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Poslednja izmena %e %B %Y o %k:%M',
'Tasks Export' => 'Izvoz zadataka',
'Tasks exportation for "%s"' => 'Izvoz zadataka za "%s"',
'Start Date' => 'Početni datum',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Želim obaveštenja samo za ovaj projekat:',
'view the task on Kanboard' => 'Pregledaj zadatke',
'Public access' => 'Javni pristup',
- 'User management' => 'Uređivanje korisnika',
'Active tasks' => 'Aktivni zadaci',
'Disable public access' => 'Zabrani javni pristup',
'Enable public access' => 'Dozvoli javni pristup',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'Email: ',
'Notifications:' => 'Obaveštenja: ',
'Notifications' => 'Obaveštenja',
- 'Group:' => 'Grupa:',
- 'Regular user' => 'Standardni korisnik',
'Account type:' => 'Vrsta naloga:',
'Edit profile' => 'Izmeni profil',
'Change password' => 'Izmeni lozinku',
'Password modification' => 'Izmena lozinke',
'External authentications' => 'Spoljne akcije',
- 'Google Account' => 'Google nalog',
- 'Github Account' => 'Github nalog',
'Never connected.' => 'Bez konekcija.',
- 'No account linked.' => 'Bez povezanih naloga.',
- 'Account linked.' => 'Nalog povezan.',
'No external authentication enabled.' => 'Bez omogućenih spoljnih autentikacija.',
'Password modified successfully.' => 'Uspešna izmena lozinke.',
'Unable to change the password.' => 'Nije moguće izmeniti lozinku.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s zamena dodele za zadatak %s na %s',
'New password for the user "%s"' => 'Nova lozinka za korisnika "%s"',
'Choose an event' => 'Izaberi događaj',
- // 'Github commit received' => '',
- // 'Github issue opened' => '',
- // 'Github issue closed' => '',
- // 'Github issue reopened' => '',
- // 'Github issue assignee change' => '',
- // 'Github issue label change' => '',
'Create a task from an external provider' => 'Kreiraj zadatak preko posrednika',
'Change the assignee based on an external username' => 'Zmień osobę odpowiedzialną na podstawie zewnętrznej nazwy użytkownika',
'Change the category based on an external label' => 'Zmień kategorię na podstawie zewnętrzenj etykiety',
// 'Reference' => '',
- // 'Reference: %s' => '',
'Label' => 'Etikieta',
'Database' => 'Baza',
'About' => 'Informacje',
@@ -474,7 +431,6 @@ return array(
// 'Frequency in second (60 seconds by default)' => '',
// 'Frequency in second (0 to disable this feature, 10 seconds by default)' => '',
'Application URL' => 'Adres URL aplikacji',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Primer: http://example.kanboard.net/ (koristi se u obaveštenjima putem mail-a)',
'Token regenerated.' => 'Token wygenerowany ponownie.',
'Date format' => 'Format daty',
'ISO format is always accepted, example: "%s" and "%s"' => 'Format ISO je uvek prihvatljiv, primer: "%s", "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Ovaj projekat je privatan',
'Type here to create a new sub-task' => 'Kucaj ovde za kreiranje novog pod-zadatka',
'Add' => 'Dodaj',
- 'Estimated time: %s hours' => 'Procenjeno vreme: %s godzin',
- 'Time spent: %s hours' => 'Utrošeno vreme: %s godzin',
- 'Started on %B %e, %Y' => 'Započeto dana %e %B %Y',
'Start date' => 'Datum početka',
'Time estimated' => 'Procenjeno vreme',
'There is nothing assigned to you.' => 'Ništa vam nije dodeljeno',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Svima je dozvoljen pristup.',
// 'Webhooks' => '',
// 'API' => '',
- // 'Github webhooks' => '',
- // 'Help on Github webhooks' => '',
// 'Create a comment from an external provider' => '',
- // 'Github issue comment created' => '',
'Project management' => 'Uređivanje projekata',
'My projects' => 'Moji projekti',
'Columns' => 'Kolone',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Zaduženja korisnika za "%s"',
'Clone this project' => 'Kopiraj projekat',
'Column removed successfully.' => 'Kolumna usunięta pomyslnie.',
- // 'Github Issue' => '',
'Not enough data to show the graph.' => 'Nedovoljno podataka za grafikon.',
'Previous' => 'Prethodni',
'The id must be an integer' => 'ID musi być liczbą całkowitą',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Osnovni razdelnik',
'Do you really want to remove this swimlane: "%s"?' => 'Da li da uklonim razdelnik: "%s"?',
'Inactive swimlanes' => 'Neaktivni razdelniki',
- 'Set project manager' => 'Podesi menadžera projekta',
- 'Set project member' => 'Podesi učesnika projekat',
'Remove a swimlane' => 'Ukloni razdelnik',
- 'Rename' => 'Preimenuj',
'Show default swimlane' => 'Prikaži osnovni razdelnik',
'Swimlane modification for the project "%s"' => 'Izmena razdelnika za projekat "%s"',
'Swimlane not found.' => 'Razdelnik nije pronađen.',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Razdelnici',
'Swimlane updated successfully.' => 'Razdelnik zaktualizowany pomyślnie.',
// '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.' => 'Razdelnik je uspešno kreiran.',
'Example: "Bug, Feature Request, Improvement"' => 'Npr: "Greška, Zahtev za izmenama, Poboljšanje"',
'Default categories for new projects (Comma-separated)' => 'Osnovne kategorije za projekat',
- // 'Gitlab commit received' => '',
- // 'Gitlab issue opened' => '',
- // 'Gitlab issue closed' => '',
- // 'Gitlab webhooks' => '',
- // 'Help on Gitlab webhooks' => '',
'Integrations' => 'Integracje',
'Integration with third-party services' => 'Integracja sa uslugama spoljnih servisa',
- 'Role for this project' => 'Uloga u ovom projektu',
- 'Project manager' => 'Manadžer projekta',
- 'Project member' => 'Učesnik projekta',
- // 'A project manager can change the settings of the project and have more privileges than a standard user.' => '',
- // 'Gitlab Issue' => '',
'Subtask Id' => 'ID pod-zadania',
'Subtasks' => 'Pod-zadataka',
'Subtasks Export' => 'Eksport pod-zadań',
@@ -603,9 +538,6 @@ return array(
// 'You already have one subtask in progress' => '',
'Which parts of the project do you want to duplicate?' => 'Koje delove projekta želite da kopirate',
// 'Disallow login form' => '',
- // 'Bitbucket commit received' => '',
- // 'Bitbucket webhooks' => '',
- // 'Help on Bitbucket webhooks' => '',
// 'Start' => '',
// 'End' => '',
// 'Task age in days' => '',
@@ -646,7 +578,6 @@ return array(
// 'This task' => '',
// '<1h' => '',
// '%dh' => '',
- // '%b %e' => '',
// 'Expand tasks' => '',
// 'Collapse tasks' => '',
// 'Expand/collapse tasks' => '',
@@ -656,14 +587,11 @@ return array(
// 'Keyboard shortcuts' => '',
// 'Open board switcher' => '',
// 'Application' => '',
- // 'since %B %e, %Y at %k:%M %p' => '',
// 'Compact view' => '',
// 'Horizontal scrolling' => '',
// 'Compact/wide view' => '',
// 'No results match:' => '',
// 'Currency' => '',
- // 'Files' => '',
- // 'Images' => '',
// 'Private project' => '',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
@@ -703,17 +631,12 @@ return array(
// 'The two factor authentication code is valid.' => '',
// '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' => '',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
// 'Burndown chart for "%s"' => '',
// 'Burndown chart' => '',
// 'This chart show the task complexity over the time (Work Remaining).' => '',
@@ -722,7 +645,6 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Disable two factor authentication' => '',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
@@ -731,7 +653,6 @@ return array(
// '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' => '',
@@ -777,21 +698,10 @@ return array(
// 'User that will receive the email' => '',
// 'Email subject' => '',
// 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
// 'Add a comment log when moving the task between columns' => '',
// 'Move the task to another column when the category is changed' => '',
// 'Send a task by email to someone' => '',
// 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
// 'Column change' => '',
// 'Position change' => '',
// 'Swimlane change' => '',
@@ -826,17 +736,11 @@ return array(
// 'The field "%s" have been updated' => '',
// 'The description have been modified' => '',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
// 'I want to receive notifications for:' => '',
// 'All tasks' => '',
// 'Only for tasks assigned to me' => '',
// 'Only for tasks created by me' => '',
// 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
// '%%Y-%%m-%%d' => '',
// 'Total for all columns' => '',
// 'You need at least 2 days of data to show the chart.' => '',
@@ -861,7 +765,6 @@ return array(
// 'Not assigned' => '',
// 'View advanced search syntax' => '',
// 'Overview' => '',
- // '%b %e %Y' => '',
// 'Board/Calendar/List view' => '',
// 'Switch to the board view' => '',
// 'Switch to the calendar view' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -906,18 +805,12 @@ return array(
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
// 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
// 'Remote user' => '',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
// 'New remote user' => '',
// 'New local user' => '',
// 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
// 'This feature does not work with all browsers.' => '',
// 'There is no destination project available.' => '',
// 'Trigger automatically subtask time tracking' => '',
@@ -932,7 +825,6 @@ return array(
// 'contributors' => '',
// 'License:' => '',
// 'License' => '',
- // 'Project Administrator' => '',
// 'Enter the text below' => '',
// 'Gantt chart for %s' => '',
// 'Sort by position' => '',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php
index 8663d600..96a28b2c 100644
--- a/app/Locale/sv_SE/translations.php
+++ b/app/Locale/sv_SE/translations.php
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Ta bort projekt',
'Edit the board for "%s"' => 'Ändra tavlan för "%s"',
'All projects' => 'Alla projekt',
- 'Change columns' => 'Ändra kolumner',
'Add a new column' => 'Lägg till ny kolumn',
'Title' => 'Titel',
'Nobody assigned' => 'Ingen tilldelad',
@@ -102,11 +101,8 @@ return array(
'Open a task' => 'Öppna en uppgift',
'Do you really want to open this task: "%s"?' => 'Vill du verkligen öppna denna uppgift: "%s"?',
'Back to the board' => 'Tillbaka till tavlan',
- 'Created on %B %e, %Y at %k:%M %p' => 'Skapad %Y-%m-%d kl %H:%M',
'There is nobody assigned' => 'Det finns ingen tilldelad',
'Column on the board:' => 'Kolumn på tavlan:',
- 'Status is open' => 'Statusen är öppen',
- 'Status is closed' => 'Statusen är stängd',
'Close this task' => 'Stäng uppgiften',
'Open this task' => 'Öppna uppgiften',
'There is no description.' => 'Det finns ingen beskrivning.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Aktuellt ID måste anges',
'The project id is required' => 'Projekt-ID måste anges',
'The project name is required' => 'Ett projektnamn måste anges',
- 'This project must be unique' => 'Detta projekt måste vara unikt',
'The title is required' => 'En titel måste anges.',
'Settings saved successfully.' => 'Inställningarna har sparats.',
'Unable to save your settings.' => 'Kunde inte spara dina ändringar',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => 'Pågående',
'Done' => 'Slutfört',
'Application version:' => 'Version:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'Slutfört %Y-%m-%d kl %H:%M',
- '%B %e, %Y at %k:%M %p' => '%Y-%m-%d kl %H:%M',
- 'Date created' => 'Skapat datum',
- 'Date completed' => 'Slutfört datum',
'Id' => 'ID',
'%d closed tasks' => '%d stängda uppgifter',
'No task for this project' => 'Inga uppgifter i detta projekt',
@@ -175,13 +166,7 @@ return array(
'Complexity' => 'Komplexitet',
'Task limit' => 'Uppgiftsbegränsning',
'Task count' => 'Antal uppgifter',
- 'Edit project access list' => 'Ändra projektåtkomst lista',
- 'Allow this user' => 'Tillåt användare',
- 'Don\'t forget that administrators have access to everything.' => 'Glöm inte att administratörerna har rätt att göra allt.',
- 'Revoke' => 'Dra tillbaka behörighet',
- 'List of authorized users' => 'Lista med behöriga användare',
'User' => 'Användare',
- 'Nobody have access to this project.' => 'Ingen har tillgång till detta projekt.',
'Comments' => 'Kommentarer',
'Write your text in Markdown' => 'Exempelsyntax för text',
'Leave a comment' => 'Lämna en kommentar',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => 'Ändra denna uppgift',
'Due Date' => 'Måldatum',
'Invalid date' => 'Ej tillåtet datum',
- 'Must be done before %B %e, %Y' => 'Måste vara klart innan %Y-%m-%d',
- '%B %e, %Y' => '%Y-%m-%d',
- '%b %e, %Y' => '%Y-%m-%d',
'Automatic actions' => 'Automatiska åtgärder',
'Your automatic action have been created successfully.' => 'Din automatiska åtgärd har skapats.',
'Unable to create your automatic action.' => 'Kunde inte skapa din automatiska åtgärd.',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => 'Tilldela en färg till en specifik användare',
'Column title' => 'Kolumnens titel',
'Position' => 'Position',
- 'Move Up' => 'Flytta upp',
- 'Move Down' => 'Flytta ned',
'Duplicate to another project' => 'Kopiera till ett annat projekt',
'Duplicate' => 'Kopiera uppgiften',
'link' => 'länk',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => 'Kommentaren har tagits bort.',
'Unable to remove this comment.' => 'Kunde inte ta bort denna kommentar.',
'Do you really want to remove this comment?' => 'Är du säker på att du vill ta bort denna kommentar?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Bara administratörer eller skaparen av kommentaren har tillgång till denna sida.',
'Current password for the user "%s"' => 'Nuvarande lösenord för användaren %s"',
'The current password is required' => 'Det nuvarande lösenordet måste anges',
'Wrong password' => 'Fel lösenord',
@@ -267,10 +246,6 @@ return array(
'External authentication failed' => 'Extern autentisering misslyckades',
'Your external account is linked to your profile successfully.' => 'Ditt externa konto länkades till din profil.',
'Email' => 'Epost',
- 'Link my Google Account' => 'Länka till mitt Google-konto',
- 'Unlink my Google Account' => 'Ta bort länken till mitt Google-konto',
- 'Login with my Google Account' => 'Logga in med mitt Google-konto',
- 'Project not found.' => 'Projektet kunde inte hittas',
'Task removed successfully.' => 'Uppgiften har tagits bort',
'Unable to remove this task.' => 'Kunde inte ta bort denna uppgift',
'Remove a task' => 'Ta bort en uppgift',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => 'Maxstorlek: ',
'Unable to upload the file.' => 'Kunde inte ladda upp filen.',
'Display another project' => 'Visa ett annat projekt',
- 'Login with my Github Account' => 'Logga in med mitt Github-konto',
- 'Link my Github Account' => 'Anslut mitt Github-konto',
- 'Unlink my Github Account' => 'Koppla ifrån mitt Github-konto',
'Created by %s' => 'Skapad av %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Senaste ändring %Y-%m-%d kl %H:%M',
'Tasks Export' => 'Exportera uppgifter',
'Tasks exportation for "%s"' => 'Exportera uppgifter för "%s"',
'Start Date' => 'Startdatum',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => 'Jag vill endast få notiser för dessa projekt:',
'view the task on Kanboard' => 'Visa uppgiften på Kanboard',
'Public access' => 'Publik åtkomst',
- 'User management' => 'Hantera användare',
'Active tasks' => 'Aktiva uppgifter',
'Disable public access' => 'Inaktivera publik åtkomst',
'Enable public access' => 'Aktivera publik åtkomst',
@@ -397,18 +367,12 @@ return array(
'Email:' => 'E-post:',
'Notifications:' => 'Notiser:',
'Notifications' => 'Notiser',
- 'Group:' => 'Grupp:',
- 'Regular user' => 'Normal användare',
'Account type:' => 'Kontotyp:',
'Edit profile' => 'Ändra profil',
'Change password' => 'Byt lösenord',
'Password modification' => 'Ändra lösenord',
'External authentications' => 'Extern autentisering',
- 'Google Account' => 'Googlekonto',
- 'Github Account' => 'Githubkonto',
'Never connected.' => 'Inte ansluten.',
- 'No account linked.' => 'Inget konto länkat.',
- 'Account linked.' => 'Konto länkat.',
'No external authentication enabled.' => 'Ingen extern autentisering aktiverad.',
'Password modified successfully.' => 'Lösenordet har ändrats.',
'Unable to change the password.' => 'Kunde inte byta lösenord.',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s byt tilldelning av uppgiften %s till %s',
'New password for the user "%s"' => 'Nytt lösenord för användaren "%s"',
'Choose an event' => 'Välj en händelse',
- 'Github commit received' => 'Github-bidrag mottaget',
- 'Github issue opened' => 'Github-fråga öppnad',
- 'Github issue closed' => 'Github-fråga stängd',
- 'Github issue reopened' => 'Github-fråga öppnad på nytt',
- 'Github issue assignee change' => 'Github-fråga ny tilldelning',
- 'Github issue label change' => 'Github-fråga etikettförändring',
'Create a task from an external provider' => 'Skapa en uppgift från en extern leverantör',
'Change the assignee based on an external username' => 'Ändra tilldelning baserat på ett externt användarnamn',
'Change the category based on an external label' => 'Ändra kategori baserat på en extern etikett',
'Reference' => 'Referens',
- 'Reference: %s' => 'Referens: %s',
'Label' => 'Etikett',
'Database' => 'Databas',
'About' => 'Om',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => 'Frekvens i sekunder (60 sekunder är standard)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekvens i sekunder (ange 0 för att inaktivera, 10 sekunder är standard)',
'Application URL' => 'Applikations-URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Exempel: http://example.kanboard.net/ (används för e-postnotiser)',
'Token regenerated.' => 'Token nyskapad.',
'Date format' => 'Datumformat',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO-format är alltid tillåtet, exempel: "%s" och "%s"',
@@ -482,9 +438,6 @@ return array(
'This project is private' => 'Det här projektet är privat',
'Type here to create a new sub-task' => 'Skriv här för att skapa en ny deluppgift',
'Add' => 'Lägg till',
- 'Estimated time: %s hours' => 'Uppskattad tid: %s timmar',
- 'Time spent: %s hours' => 'Nedlaggd tid: %s timmar',
- 'Started on %B %e, %Y' => 'Startad %Y-%m-%d',
'Start date' => 'Startdatum',
'Time estimated' => 'Uppskattad tid',
'There is nothing assigned to you.' => 'Du har inget tilldelat till dig.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Alla har tillgång till projektet',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github webhooks',
- 'Help on Github webhooks' => 'Hjälp för Github webhooks',
'Create a comment from an external provider' => 'Skapa en kommentar från en extern leverantör',
- 'Github issue comment created' => 'Github frågekommentar skapad',
'Project management' => 'Projekthantering',
'My projects' => 'Mina projekt',
'Columns' => 'Kolumner',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => 'Användardeltagande för "%s"',
'Clone this project' => 'Klona projektet',
'Column removed successfully.' => 'Kolumnen togs bort',
- 'Github Issue' => 'Github fråga',
'Not enough data to show the graph.' => 'Inte tillräckligt med data för att visa graf',
'Previous' => 'Föregående',
'The id must be an integer' => 'ID måste vara ett heltal',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Standard swimlane',
'Do you really want to remove this swimlane: "%s"?' => 'Vill du verkligen ta bort denna swimlane: "%s"?',
'Inactive swimlanes' => 'Inaktiv swimlane',
- 'Set project manager' => 'Sätt Projektadministratör',
- 'Set project member' => 'Sätt projektmedlem',
'Remove a swimlane' => 'Ta bort en swimlane',
- 'Rename' => 'Byt namn',
'Show default swimlane' => 'Visa standard swimlane',
'Swimlane modification for the project "%s"' => 'Ändra swimlane för projektet "%s"',
'Swimlane not found.' => 'Swimlane kunde inte hittas',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Swimlanes',
'Swimlane updated successfully.' => 'Swimlane uppdaterad',
'The default swimlane have been updated successfully.' => 'Standardswimlane har uppdaterats',
- 'Unable to create your swimlane.' => 'Kunde inte skapa din swimlane',
'Unable to remove this swimlane.' => 'Kunde inte ta bort swimlane',
'Unable to update this swimlane.' => 'Kunde inte uppdatera swimlane',
'Your swimlane have been created successfully.' => 'Din swimlane har skapats',
'Example: "Bug, Feature Request, Improvement"' => 'Exempel: "Bug, ny funktionalitet, förbättringar"',
'Default categories for new projects (Comma-separated)' => 'Standardkategorier för nya projekt (komma-separerade)',
- 'Gitlab commit received' => 'Gitlab bidrag mottaget',
- 'Gitlab issue opened' => 'Gitlab fråga öppnad',
- 'Gitlab issue closed' => 'Gitlab fråga stängd',
- 'Gitlab webhooks' => 'Gitlab webhooks',
- 'Help on Gitlab webhooks' => 'Hjälp för Gitlab webhooks',
'Integrations' => 'Integrationer',
'Integration with third-party services' => 'Integration med tjänst från tredjepart',
- 'Role for this project' => 'Roll för detta projekt',
- 'Project manager' => 'Projektadministratör',
- 'Project member' => 'Projektmedlem',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'En projektadministratör kan ändra inställningar för projektet och har mer rättigheter än en standardanvändare.',
- 'Gitlab Issue' => 'Gitlab fråga',
'Subtask Id' => 'Deluppgifts-ID',
'Subtasks' => 'Deluppgift',
'Subtasks Export' => 'Export av deluppgifter',
@@ -603,9 +538,6 @@ return array(
'You already have one subtask in progress' => 'Du har redan en deluppgift igång',
'Which parts of the project do you want to duplicate?' => 'Vilka delar av projektet vill du duplicera?',
// 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Bitbucket bidrag mottaget',
- 'Bitbucket webhooks' => 'Bitbucket webhooks',
- 'Help on Bitbucket webhooks' => 'Hjälp för Bitbucket webhooks',
'Start' => 'Start',
'End' => 'Slut',
'Task age in days' => 'Uppgiftsålder i dagar',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Denna uppgift',
'<1h' => '<1h',
'%dh' => '%dh',
- '%b %e' => '%b %e',
'Expand tasks' => 'Expandera uppgifter',
'Collapse tasks' => 'Minimera uppgifter',
'Expand/collapse tasks' => 'Expandera/minimera uppgifter',
@@ -656,14 +587,11 @@ return array(
'Keyboard shortcuts' => 'Tangentbordsgenvägar',
'Open board switcher' => 'Växling av öppen tavla',
'Application' => 'Applikation',
- 'since %B %e, %Y at %k:%M %p' => 'sedan %B %e, %Y at %k:%M %p',
'Compact view' => 'Kompakt vy',
'Horizontal scrolling' => 'Horisontell scroll',
'Compact/wide view' => 'Kompakt/bred vy',
'No results match:' => 'Inga matchande resultat',
'Currency' => 'Valuta',
- 'Files' => 'Filer',
- 'Images' => 'Bilder',
'Private project' => 'Privat projekt',
'AUD - Australian Dollar' => 'AUD - Australiska dollar',
'CAD - Canadian Dollar' => 'CAD - Kanadensiska dollar',
@@ -703,17 +631,12 @@ return array(
'The two factor authentication code is valid.' => 'Tvåfaktorsverifieringskoden är giltig.',
'Code' => 'Kod',
'Two factor authentication' => 'Tvåfaktorsverifiering',
- 'Enable/disable two factor authentication' => 'Aktivera/avaktivera tvåfaktorsverifiering',
'This QR code contains the key URI: ' => 'Denna QR-kod innehåller nyckel-URI:n',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Spara säkerhetsnyckeln i din TOTP mjukvara (med exempelvis Google Authenticator eller FreeOTP).',
'Check my code' => 'Kolla min kod',
'Secret key: ' => 'Säkerhetsnyckel:',
'Test your device' => 'Testa din enhet',
'Assign a color when the task is moved to a specific column' => 'Tilldela en färg när uppgiften flyttas till en specifik kolumn',
'%s via Kanboard' => '%s via Kanboard',
- 'uploaded by: %s' => 'uppladdad av: %s',
- 'uploaded on: %s' => 'uppladdad på: %s',
- 'size: %s' => 'storlek: %s',
'Burndown chart for "%s"' => 'Burndown diagram för "%s"',
'Burndown chart' => 'Burndown diagram',
'This chart show the task complexity over the time (Work Remaining).' => 'Diagrammet visar uppgiftens svårighet över tid (återstående arbete).',
@@ -722,7 +645,6 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Ta en skärmdump och tryck CTRL+V för att klistra in här.',
'Screenshot uploaded successfully.' => 'Skärmdumpen laddades upp.',
'SEK - Swedish Krona' => 'SEK - Svensk Krona',
- 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Projektidentifieraren är en valbar alfanumerisk kod som används för att identifiera ditt projekt.',
'Identifier' => 'Identifierare',
'Disable two factor authentication' => 'Inaktivera två-faktors autentisering',
'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Vill du verkligen inaktivera två-faktors autentisering för denna användare: "%s"?',
@@ -731,7 +653,6 @@ return array(
'A task cannot be linked to itself' => 'En uppgift kan inte länkas till sig själv',
'The exact same link already exists' => 'Länken existerar redan',
'Recurrent task is scheduled to be generated' => 'Återkommande uppgift är schemalagd att genereras',
- 'Recurring information' => 'Återkommande information',
'Score' => 'Poäng',
'The identifier must be unique' => 'Identifieraren måste vara unik',
'This linked task id doesn\'t exists' => 'Denna länkade uppgifts id existerar inte',
@@ -777,21 +698,10 @@ return array(
'User that will receive the email' => 'Användare som kommer att ta emot mailet',
'Email subject' => 'E-post ämne',
'Date' => 'Datum',
- 'By @%s on Bitbucket' => 'Av @%s på Bitbucket',
- 'Bitbucket Issue' => 'Bitbucket fråga',
- 'Commit made by @%s on Bitbucket' => 'Bidrag gjort av @%s på Bitbucket',
- 'Commit made by @%s on Github' => 'Bidrag gjort av @%s på Github',
- 'By @%s on Github' => 'Av @%s på Github',
- 'Commit made by @%s on Gitlab' => 'Bidrag gjort av @%s på Gitlab',
'Add a comment log when moving the task between columns' => 'Lägg till en kommentarslogg när en uppgift flyttas mellan kolumner',
'Move the task to another column when the category is changed' => 'Flyttas uppgiften till en annan kolumn när kategorin ändras',
'Send a task by email to someone' => 'Skicka en uppgift med e-post till någon',
'Reopen a task' => 'Återöppna en uppgift',
- 'Bitbucket issue opened' => 'Bitbucketfråga öppnad',
- 'Bitbucket issue closed' => 'Bitbucketfråga stängd',
- 'Bitbucket issue reopened' => 'Bitbucketfråga återöppnad',
- 'Bitbucket issue assignee change' => 'Bitbucketfråga tilldelningsändring',
- 'Bitbucket issue comment created' => 'Bitbucketfråga kommentar skapad',
'Column change' => 'Kolumnändring',
'Position change' => 'Positionsändring',
'Swimlane change' => 'Swimlaneändring',
@@ -826,17 +736,11 @@ return array(
'The field "%s" have been updated' => 'Fältet "%s" har uppdaterats',
'The description have been modified' => 'Beskrivningen har modifierats',
'Do you really want to close the task "%s" as well as all subtasks?' => 'Vill du verkligen stänga uppgiften "%s" och alla deluppgifter?',
- 'Swimlane: %s' => 'Swimlane: %s',
'I want to receive notifications for:' => 'Jag vill få notiser för:',
'All tasks' => 'Alla uppgifter',
'Only for tasks assigned to me' => 'Bara för uppgifter tilldelade mig',
'Only for tasks created by me' => 'Bara för uppgifter skapade av mig',
'Only for tasks created by me and assigned to me' => 'Bara för uppgifter skapade av mig och tilldelade till mig',
- '%A' => '%A',
- '%b %e, %Y, %k:%M %p' => '%b %e, %Y, %k:%M %p',
- 'New due date: %B %e, %Y' => 'Nytt förfallodatum: %B %e, %Y',
- 'Start date changed: %B %e, %Y' => 'Startdatum ändrat: %B %e, %Y',
- '%k:%M %p' => '%k:%M %p',
'%%Y-%%m-%%d' => '%%Y-%%m-%%d',
'Total for all columns' => 'Totalt för alla kolumner',
'You need at least 2 days of data to show the chart.' => 'Du behöver minst två dagars data för att visa diagrammet.',
@@ -861,7 +765,6 @@ return array(
'Not assigned' => 'Inte tilldelad',
'View advanced search syntax' => 'Visa avancerad söksyntax',
'Overview' => 'Översikts',
- '%b %e %Y' => '%b %e %Y',
'Board/Calendar/List view' => 'Tavla/Kalender/Listvy',
'Switch to the board view' => 'Växla till tavelvy',
'Switch to the calendar view' => 'Växla till kalendervy',
@@ -894,10 +797,6 @@ return array(
'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Diagramet visar medel av led och cykeltid för de senaste %d uppgifterna över tiden.',
'Average time into each column' => 'Medeltidsåtgång i varje kolumn',
'Lead and cycle time' => 'Led och cykeltid',
- 'Google Authentication' => 'Google autentisering',
- 'Help on Google authentication' => 'Hjälp för Google autentisering',
- 'Github Authentication' => 'Github autentisering',
- 'Help on Github authentication' => 'Hjälp för Github autentisering',
'Lead time: ' => 'Ledtid',
'Cycle time: ' => 'Cykeltid',
'Time spent into each column' => 'Tidsåtgång per kolumn',
@@ -906,18 +805,12 @@ return array(
'If the task is not closed the current time is used instead of the completion date.' => 'Om uppgiften inte är stängd används nuvarande tid istället för slutförandedatum.',
'Set automatically the start date' => 'Sätt startdatum automatiskt',
'Edit Authentication' => 'Ändra autentisering',
- 'Google Id' => 'Google Id',
- 'Github Id' => 'Github Id',
'Remote user' => 'Extern användare',
'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Externa användares lösenord lagras inte i Kanboard-databasen, exempel: LDAP, Google och Github-konton.',
'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Om du aktiverar boxen "Tillåt inte loginformulär" kommer inloggningsuppgifter i formuläret att ignoreras.',
- 'By @%s on Gitlab' => 'Av @%s på Gitlab',
- 'Gitlab issue comment created' => 'Gitlab frågekommentar skapad',
'New remote user' => 'Ny extern användare',
'New local user' => 'Ny lokal användare',
'Default task color' => 'Standardfärg för uppgifter',
- 'Hide sidebar' => 'Göm sidokolumn',
- 'Expand sidebar' => 'Expandera sidokolumn',
'This feature does not work with all browsers.' => 'Denna funktion fungerar inte i alla webbläsare.',
'There is no destination project available.' => 'Det finns inget destinationsprojekt tillgängligt.',
'Trigger automatically subtask time tracking' => 'Aktivera automatisk tidsbevakning av deluppgifter',
@@ -932,7 +825,6 @@ return array(
'contributors' => 'bidragare:',
'License:' => 'Licens:',
'License' => 'Licens',
- 'Project Administrator' => 'Projektadministratör',
'Enter the text below' => 'Fyll i texten nedan',
'Gantt chart for %s' => 'Gantt-schema för %s',
'Sort by position' => 'Sortera efter position',
@@ -952,11 +844,9 @@ return array(
// 'open file' => '',
// 'End date' => '',
// 'Users overview' => '',
- // 'Managers' => '',
// 'Members' => '',
// 'Shared project' => '',
// 'Project managers' => '',
- // 'Project members' => '',
// 'Gantt chart for all projects' => '',
// 'Projects list' => '',
// 'Gantt chart for this project' => '',
@@ -964,26 +854,16 @@ return array(
// 'End date:' => '',
// 'There is no start date or end date for this project.' => '',
// 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
// 'Link type' => '',
// 'Change task color when using a specific task link' => '',
// 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
// 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
// 'Documentation: %s' => '',
// 'Switch to the Gantt chart view' => '',
// 'Reset the search/filter box' => '',
// 'Documentation' => '',
// 'Table of contents' => '',
// 'Gantt' => '',
- // 'Help with project permissions' => '',
// 'Author' => '',
// 'Version' => '',
// 'Plugins' => '',
@@ -1046,7 +926,6 @@ return array(
// 'Append/Replace' => '',
// 'Append' => '',
// 'Replace' => '',
- // 'There is no notification method registered.' => '',
// 'Import' => '',
// 'change sorting' => '',
// 'Tasks Importation' => '',
@@ -1064,4 +943,209 @@ return array(
// 'Duplicates are not imported' => '',
// 'Usernames must be lowercase and unique' => '',
// 'Passwords will be encrypted if present' => '',
+ // '%s attached a new file to the task %s' => '',
+ // 'Assign automatically a category based on a link' => '',
+ // 'BAM - Konvertible Mark' => '',
+ // 'Assignee Username' => '',
+ // 'Assignee Name' => '',
+ // 'Groups' => '',
+ // 'Members of %s' => '',
+ // 'New group' => '',
+ // 'Group created successfully.' => '',
+ // 'Unable to create your group.' => '',
+ // 'Edit group' => '',
+ // 'Group updated successfully.' => '',
+ // 'Unable to update your group.' => '',
+ // 'Add group member to "%s"' => '',
+ // 'Group member added successfully.' => '',
+ // 'Unable to add group member.' => '',
+ // 'Remove user from group "%s"' => '',
+ // 'User removed successfully from this group.' => '',
+ // 'Unable to remove this user from the group.' => '',
+ // 'Remove group' => '',
+ // 'Group removed successfully.' => '',
+ // 'Unable to remove this group.' => '',
+ // 'Project Permissions' => '',
+ // 'Manager' => '',
+ // 'Project Manager' => '',
+ // 'Project Member' => '',
+ // 'Project Viewer' => '',
+ // 'Your account is locked for %d minutes' => '',
+ // 'Invalid captcha' => '',
+ // 'The name must be unique' => '',
+ // 'View all groups' => '',
+ // 'View group members' => '',
+ // 'There is no user available.' => '',
+ // 'Do you really want to remove the user "%s" from the group "%s"?' => '',
+ // 'There is no group.' => '',
+ // 'External Id' => '',
+ // 'Add group member' => '',
+ // 'Do you really want to remove this group: "%s"?' => '',
+ // 'There is no user in this group.' => '',
+ // 'Remove this user' => '',
+ // 'Permissions' => '',
+ // 'Allowed Users' => '',
+ // 'No user have been allowed specifically.' => '',
+ // 'Role' => '',
+ // 'Enter user name...' => '',
+ // 'Allowed Groups' => '',
+ // 'No group have been allowed specifically.' => '',
+ // 'Group' => '',
+ // 'Group Name' => '',
+ // 'Enter group name...' => '',
+ // 'Role:' => '',
+ // 'Project members' => '',
+ // 'Compare hours for "%s"' => '',
+ // '%s mentioned you in the task #%d' => '',
+ // '%s mentioned you in a comment on the task #%d' => '',
+ // 'You were mentioned in the task #%d' => '',
+ // 'You were mentioned in a comment on the task #%d' => '',
+ // 'Mentioned' => '',
+ // 'Compare Estimated Time vs Actual Time' => '',
+ // 'Estimated hours: ' => '',
+ // 'Actual hours: ' => '',
+ // 'Hours Spent' => '',
+ // 'Hours Estimated' => '',
+ // 'Estimated Time' => '',
+ // 'Actual Time' => '',
+ // 'Estimated vs actual time' => '',
+ // 'RUB - Russian Ruble' => '',
+ // 'Assign the task to the person who does the action when the column is changed' => '',
+ // 'Close a task in a specific column' => '',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ // 'Password Reset for Kanboard' => '',
+ // 'Forgot password?' => '',
+ // 'Enable "Forget Password"' => '',
+ // 'Password Reset' => '',
+ // 'New password' => '',
+ // 'Change Password' => '',
+ // 'To reset your password click on this link:' => '',
+ // 'Last Password Reset' => '',
+ // 'The password has never been reinitialized.' => '',
+ // 'Creation' => '',
+ // 'Expiration' => '',
+ // 'Password reset history' => '',
+ // 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '',
+ // 'Do you really want to close all tasks of this column?' => '',
+ // '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '',
+ // 'Close all tasks of this column' => '',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php
index 50181c79..f26ee30d 100644
--- a/app/Locale/th_TH/translations.php
+++ b/app/Locale/th_TH/translations.php
@@ -20,15 +20,15 @@ return array(
'Red' => 'สีแดง',
'Orange' => 'สีส้ม',
'Grey' => 'สีเทา',
- // 'Brown' => '',
- // 'Deep Orange' => '',
- // 'Dark Grey' => '',
- // 'Pink' => '',
- // 'Teal' => '',
- // 'Cyan' => '',
- // 'Lime' => '',
- // 'Light Green' => '',
- // 'Amber' => '',
+ 'Brown' => 'สีน้ำตาล',
+ 'Deep Orange' => 'สีส้มเข้ม',
+ 'Dark Grey' => 'สีเทาเข้ม',
+ 'Pink' => 'สีชมพู',
+ 'Teal' => 'สีเขียวหัวเป็ด',
+ 'Cyan' => 'สีฟ้า',
+ 'Lime' => 'สีมะนาว',
+ 'Light Green' => 'สีเขียวสว่าง',
+ 'Amber' => 'สีเหลืองอำพัน',
'Save' => 'บันทึก',
'Login' => 'เข้าสู่ระบบ',
'Official website:' => 'เวบไซต์อย่างเป็นทางการ:',
@@ -65,8 +65,8 @@ return array(
'%d tasks in total' => '%d งานทั้งหมด',
'Unable to update this board.' => 'ไม่สามารถปรับปรุงบอร์ดได้.',
'Edit board' => 'แก้ไขบอร์ด',
- 'Disable' => 'ปิด',
- 'Enable' => 'เปิด',
+ 'Disable' => 'ปิดการทำงาน',
+ 'Enable' => 'เปิดการทำงาน',
'New project' => 'โปรเจคใหม่',
'Do you really want to remove this project: "%s"?' => 'คุณต้องการเอาโปรเจค « %s » ออกใช่หรือไม่?',
'Remove project' => 'ลบโปรเจค',
@@ -102,11 +102,8 @@ return array(
'Open a task' => 'เปิดงาน',
'Do you really want to open this task: "%s"?' => 'คุณต้องการเปิดงาน: « %s » ใช่หรือไม่?',
'Back to the board' => 'กลับไปที่บอร์ด',
- 'Created on %B %e, %Y at %k:%M %p' => 'สร้างวันที่ %d/%m/%Y เวลา %H:%M',
'There is nobody assigned' => 'ไม่มีใครถูกกำหนด',
'Column on the board:' => 'คอลัมน์บนบอร์ด:',
- 'Status is open' => 'สถานะเปิด',
- 'Status is closed' => 'สถานะปิด',
'Close this task' => 'ปิดงานนี้',
'Open this task' => 'เปิดงานนี้',
'There is no description.' => 'ไม่มีคำอธิบาย',
@@ -124,7 +121,6 @@ return array(
'The id is required' => 'ต้องการไอดี',
'The project id is required' => 'ต้องการไอดีโปรเจค',
'The project name is required' => 'ต้องการชื่อโปรเจค',
- 'This project must be unique' => 'ชื่อโปรเจคต้องไม่ซ้ำ',
'The title is required' => 'ต้องการหัวเรื่อง',
'Settings saved successfully.' => 'บันทึกการตั้งค่าเรียบร้อยแล้ว',
'Unable to save your settings.' => 'ไม่สามารถบันทึกการตั้งค่าได้',
@@ -159,10 +155,6 @@ return array(
'Work in progress' => 'กำลังทำ',
'Done' => 'เสร็จ',
'Application version:' => 'แอพเวอร์ชัน:',
- 'Completed on %B %e, %Y at %k:%M %p' => 'เรียบร้อยวันที่ %d/%m/%Y เวลา %H:%M',
- '%B %e, %Y at %k:%M %p' => '%d/%m/%Y เวลา %H:%M',
- 'Date created' => 'สร้างวันที่',
- 'Date completed' => 'เรียบร้อยวันที่',
'Id' => 'ไอดี',
'%d closed tasks' => '%d งานที่ปิด',
'No task for this project' => 'ไม่มีงานสำหรับโปรเจคนี้',
@@ -175,13 +167,7 @@ return array(
'Complexity' => 'ความซับซ้อน',
'Task limit' => 'จำกัดงาน',
'Task count' => 'นับงาน',
- 'Edit project access list' => 'แก้ไขการเข้าถึงรายชื่อโปรเจค',
- 'Allow this user' => 'อนุญาตผู้ใช้นี้',
- 'Don\'t forget that administrators have access to everything.' => 'อย่าลืมผู้ดูแลระบบสามารถเข้าถึงได้ทุกอย่าง',
- 'Revoke' => 'ยกเลิก',
- 'List of authorized users' => 'รายชื่อผู้ใช้ที่ได้รับการยืนยัน',
'User' => 'ผู้ใช้',
- // 'Nobody have access to this project.' => '',
'Comments' => 'ความคิดเห็น',
'Write your text in Markdown' => 'เขียนข้อความในรูปแบบ Markdown',
'Leave a comment' => 'ออกความคิดเห็น',
@@ -192,9 +178,6 @@ return array(
'Edit this task' => 'แก้ไขงาน',
'Due Date' => 'วันที่ครบกำหนด',
'Invalid date' => 'วันที่ผิด',
- 'Must be done before %B %e, %Y' => 'ต้องทำให้เสร็จก่อน %d/%m/%Y',
- '%B %e, %Y' => '%d/%m/%Y',
- // '%b %e, %Y' => '',
'Automatic actions' => 'การกระทำอัตโนมัติ',
'Your automatic action have been created successfully.' => 'การกระทำอัตโนมัติสร้างเรียบร้อยแล้ว',
'Unable to create your automatic action.' => 'ไม่สามารถสร้างการกระทำอัตโนมัติได้',
@@ -236,7 +219,6 @@ return array(
'Comment removed successfully.' => 'ลบความคิดเห็นเรียบร้อยแล้ว',
'Unable to remove this comment.' => 'ไม่สามารถลบความคิดเห็นได้',
'Do you really want to remove this comment?' => 'คุณต้องการลบความคิดเห็น',
- 'Only administrators or the creator of the comment can access to this page.' => 'เฉพาะผู้ดูแลระบบหรือผู้สร้างความคิดเห็นเข้าถึงหน้านี้',
'Current password for the user "%s"' => 'รหัสผ่านปัจจุบันของผู้ใช้ « %s »',
'The current password is required' => 'ต้องการรหัสผ่านปัจจุบัน',
'Wrong password' => 'รหัสผ่านผิด',
@@ -262,39 +244,35 @@ return array(
'%d comments' => '%d ความคิดเห็น',
'%d comment' => '%d ความคิดเห็น',
'Email address invalid' => 'อีเมลผิด',
- // 'Your external account is not linked anymore to your profile.' => '',
- // 'Unable to unlink your external account.' => '',
- // 'External authentication failed' => '',
- // 'Your external account is linked to your profile successfully.' => '',
+ 'Your external account is not linked anymore to your profile.' => 'บัญชีภายนอกของคุณไม่ได้เชื่อมโยงอีกต่อไปในโปรไฟล์ของคุณ',
+ 'Unable to unlink your external account.' => 'ไม่สามารถยกเลิกการเชื่อมโยงบัญชีภายนอกของคุณ',
+ 'External authentication failed' => 'การตรวจสอบภายนอกล้มเหลว',
+ 'Your external account is linked to your profile successfully.' => 'บัญชีภายนอกของคุณลิงค์กับโปรไฟล์ของคุณเรียบร้อย',
'Email' => 'อีเมล',
- 'Link my Google Account' => 'เชื่อมต่อกับกูเกิลแอคเคาท์',
- 'Unlink my Google Account' => 'ไม่เชื่อมต่อกับกูเกิลแอคเคาท์',
- 'Login with my Google Account' => 'เข้าใช้ด้วยกูเกิลแอคเคาท์',
- 'Project not found.' => 'หาโปรเจคไม่พบ',
'Task removed successfully.' => 'ลบงานเรียบร้อยแล้ว',
'Unable to remove this task.' => 'ไม่สามารถลบงานนี้',
'Remove a task' => 'ลบงาาน',
'Do you really want to remove this task: "%s"?' => 'คุณต้องการลบงาน "%s" ออกใช่หรือไม่?',
- 'Assign automatically a color based on a category' => 'กำหนดสีอัตโนมัติขึ้นอยู่กับกลุ่ม',
- 'Assign automatically a category based on a color' => 'กำหนดกลุ่มอัตโนมัติขึ้นอยู่กับสี',
+ 'Assign automatically a color based on a category' => 'กำหนดสีอัตโนมัติขึ้นอยู่กับหมวด',
+ 'Assign automatically a category based on a color' => 'กำหนดหมวดอัตโนมัติขึ้นอยู่กับสี',
'Task creation or modification' => 'สร้างหรือแก้ไขงาน',
- 'Category' => 'กลุ่ม',
- 'Category:' => 'กลุ่ม:',
- 'Categories' => 'กลุ่ม',
- 'Category not found.' => 'ไม่พบกลุ่ม.',
- 'Your category have been created successfully.' => 'สร้างกลุ่มเรียบร้อยแล้ว',
- 'Unable to create your category.' => 'ไม่สามารถสร้างกลุ่มได้',
- 'Your category have been updated successfully.' => 'ปรับปรุงกลุ่มเรียบร้อยแล้ว',
- 'Unable to update your category.' => 'ไม่สามารถปรับปรุงกลุ่มได้',
- 'Remove a category' => 'ลบกลุ่ม',
- 'Category removed successfully.' => 'ลบกลุ่มเรียบร้อยแล้ว',
- 'Unable to remove this category.' => 'ไม่สามารถลบกลุ่มได้',
- 'Category modification for the project "%s"' => 'แก้ไขกลุ่มสำหรับโปรเจค "%s"',
- 'Category Name' => 'ชื่อกลุ่ม',
- 'Add a new category' => 'เพิ่มกลุ่มใหม่',
- 'Do you really want to remove this category: "%s"?' => 'คุณต้องการลบกลุ่ม "%s" ใช่หรือไม่?',
- 'All categories' => 'กลุ่มทั้งหมด',
- 'No category' => 'ไม่มีกลุ่ม',
+ 'Category' => 'หมวด',
+ 'Category:' => 'หมวด:',
+ 'Categories' => 'หมวด',
+ 'Category not found.' => 'ไม่พบหมวด',
+ 'Your category have been created successfully.' => 'สร้างหมวดเรียบร้อยแล้ว',
+ 'Unable to create your category.' => 'ไม่สามารถสร้างหมวดได้',
+ 'Your category have been updated successfully.' => 'ปรับปรุงหมวดเรียบร้อยแล้ว',
+ 'Unable to update your category.' => 'ไม่สามารถปรับปรุงหมวดได้',
+ 'Remove a category' => 'ลบหมวด',
+ 'Category removed successfully.' => 'ลบหมวดเรียบร้อยแล้ว',
+ 'Unable to remove this category.' => 'ไม่สามารถลบหมวดได้',
+ 'Category modification for the project "%s"' => 'แก้ไขหมวดสำหรับโปรเจค "%s"',
+ 'Category Name' => 'ชื่อหมวด',
+ 'Add a new category' => 'เพิ่มหมวดใหม่',
+ 'Do you really want to remove this category: "%s"?' => 'คุณต้องการลบหมวด "%s" ใช่หรือไม่?',
+ 'All categories' => 'หมวดทั้งหมด',
+ 'No category' => 'ไม่มีหมวด',
'The name is required' => 'ต้องการชื่อ',
'Remove a file' => 'ลบไฟล์',
'Unable to remove this file.' => 'ไม่สามารถลบไฟล์ได้',
@@ -317,7 +295,7 @@ return array(
'estimated' => 'ประมาณ',
'Sub-Tasks' => 'งานย่อย',
'Add a sub-task' => 'เพิ่มงานย่อย',
- // 'Original estimate' => '',
+ 'Original estimate' => 'ประมาณการเดิม',
'Create another sub-task' => 'สร้างงานย่อยอื่น',
'Time spent' => 'ใช้เวลา',
'Edit a sub-task' => 'แก้ไขงานย่อย',
@@ -334,11 +312,7 @@ return array(
'Maximum size: ' => 'ขนาดสูงสุด:',
'Unable to upload the file.' => 'ไม่สามารถอัพโหลดไฟล์ได้',
'Display another project' => 'แสดงโปรเจคอื่น',
- 'Login with my Github Account' => 'เข้าใช้ด้วยกิทฮับแอคเคาท์',
- 'Link my Github Account' => 'เชื่อมกับกิทฮับแอคเคาท์',
- 'Unlink my Github Account' => 'ยกเลิกการเชื่อมกับกิทอับแอคเคาท์',
'Created by %s' => 'สร้างโดย %s',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'แก้ไขล่าสุดวันที่ %B %e, %Y เวลา %k:%M %p',
'Tasks Export' => 'ส่งออกงาน',
'Tasks exportation for "%s"' => 'ส่งออกงานสำหรับ "%s"',
'Start Date' => 'เริ่มวันที่',
@@ -374,7 +348,6 @@ return array(
'I want to receive notifications only for those projects:' => 'ฉันต้องการรับการแจ้งเตือนสำหรับโปรเจค:',
'view the task on Kanboard' => 'แสดงงานบน Kanboard',
'Public access' => 'การเข้าถึงสาธารณะ',
- 'User management' => 'การจัดการผู้ใช้',
'Active tasks' => 'งานที่กำลังใช้งาน',
'Disable public access' => 'ปิดการเข้าถึงสาธารณะ',
'Enable public access' => 'เปิดการเข้าถึงสาธารณะ',
@@ -397,23 +370,17 @@ return array(
'Email:' => 'อีเมล:',
'Notifications:' => 'แจ้งเตือน:',
'Notifications' => 'การแจ้งเตือน',
- 'Group:' => 'กลุ่ม:',
- 'Regular user' => 'ผู้ใช้ปกติ:',
- 'Account type:' => 'ชนิดบัญชี:',
+ 'Account type:' => 'ประเภทบัญชี:',
'Edit profile' => 'แก้ไขประวัติ',
'Change password' => 'เปลี่ยนรหัสผ่าน',
'Password modification' => 'แก้ไขรหัสผ่าน',
'External authentications' => 'การยืนยันภายนอก',
- 'Google Account' => 'กูเกิลแอคเคาท์',
- 'Github Account' => 'กิทฮับแอคเคาท์',
'Never connected.' => 'ไม่เชื่อมต่อ',
- 'No account linked.' => 'แอคเคาท์ไม่มีการเชื่อม',
- 'Account linked.' => 'แอคเคาท์เชื่อมต่อแล้ว',
'No external authentication enabled.' => 'ไม่เปิดการใช้งานการยืนยันภายนอก',
'Password modified successfully.' => 'แก้ไขรหัสผ่านเรียบร้อยแล้ว',
'Unable to change the password.' => 'ไม่สามารถเปลี่ยนรหัสผ่านได้',
- 'Change category for the task "%s"' => 'เปลี่ยนกลุ่มสำหรับงาน "%s"',
- 'Change category' => 'เปลี่ยนกลุ่ม',
+ 'Change category for the task "%s"' => 'เปลี่ยนหมวดสำหรับงาน "%s"',
+ 'Change category' => 'เปลี่ยนหมวด',
'%s updated the task %s' => '%s ปรับปรุงงานแล้ว %s',
'%s opened the task %s' => '%s เปิดงานแล้ว %s',
'%s moved the task %s to the position #%d in the column "%s"' => '%s ย้ายงานแล้ว %s ไปตำแหน่ง #%d ในคอลัมน์ "%s"',
@@ -442,21 +409,14 @@ return array(
'Default values are "%s"' => 'ค่าเริ่มต้น "%s"',
'Default columns for new projects (Comma-separated)' => 'คอลัมน์เริ่มต้นสำหรับโปรเจคใหม่ (Comma-separated)',
'Task assignee change' => 'เปลี่ยนการกำหนดบุคคลของงาน',
- // '%s change the assignee of the task #%d to %s' => '',
- // '%s changed the assignee of the task %s to %s' => '',
+ '%s change the assignee of the task #%d to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน #%d เป็น %s',
+ '%s changed the assignee of the task %s to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน %s เป็น %s',
'New password for the user "%s"' => 'รหัสผ่านใหม่สำหรับผู้ใช้ "%s"',
'Choose an event' => 'เลือกเหตุการณ์',
- // 'Github commit received' => '',
- // 'Github issue opened' => '',
- // 'Github issue closed' => '',
- // 'Github issue reopened' => '',
- // 'Github issue assignee change' => '',
- // 'Github issue label change' => '',
- // 'Create a task from an external provider' => '',
- // 'Change the assignee based on an external username' => '',
- // 'Change the category based on an external label' => '',
- // 'Reference' => '',
- // 'Reference: %s' => '',
+ 'Create a task from an external provider' => 'สร้างงานจากบริการภายนอก',
+ 'Change the assignee based on an external username' => 'เปลี่ยนผู้รับผิดชอบขึ้นอยู่กับชื่อผู้ใช้ภายนอก',
+ 'Change the category based on an external label' => 'เปลี่ยนหมวดขึ้นอยู่กับป้ายชื่อภายนอก',
+ 'Reference' => 'อ้างถึง',
'Label' => 'ป้ายชื่อ',
'Database' => 'ฐานข้อมูล',
'About' => 'เกี่ยวกับ',
@@ -474,17 +434,13 @@ return array(
'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)' => 'ตัวอย่าง: http://example.kanboard.net/ (ถูกใช้ในการแจ้งเตือนทางอีเมล์)',
// 'Token regenerated.' => '',
'Date format' => 'รูปแบบวันที่',
- // 'ISO format is always accepted, example: "%s" and "%s"' => '',
+ 'ISO format is always accepted, example: "%s" and "%s"' => 'ยอมรับรูปแบบ ISO ตัวอย่าง: "%s" และ "%s"',
'New private project' => 'เพิ่มโปรเจคส่วนตัวใหม่',
'This project is private' => 'โปรเจคนี้เป็นโปรเจคส่วนตัว',
- // 'Type here to create a new sub-task' => '',
+ 'Type here to create a new sub-task' => 'พิมพ์ที่นี้เพื่อสร้างงานย่อยใหม่',
'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.' => 'ไม่มีอะไรกำหนดให้คุณ',
@@ -496,10 +452,7 @@ return array(
'Everybody have access to this project.' => 'ทุกคนสามารถเข้าถึงโปรเจคนี้',
// 'Webhooks' => '',
// 'API' => '',
- // 'Github webhooks' => '',
- // 'Help on Github webhooks' => '',
- // 'Create a comment from an external provider' => '',
- // 'Github issue comment created' => '',
+ 'Create a comment from an external provider' => 'สร้างความคิดเห็นจากบริการภายนอก',
'Project management' => 'การจัดการโปรเจค',
'My projects' => 'โปรเจคของฉัน',
'Columns' => 'คอลัมน์',
@@ -517,7 +470,6 @@ return array(
'User repartition for "%s"' => 'การแบ่งงานของผู้ใช้ "%s"',
'Clone this project' => 'เลียนแบบโปรเจคนี้',
'Column removed successfully.' => 'ลบคอลัมน์สำเร็จ',
- // 'Github Issue' => '',
'Not enough data to show the graph.' => 'ไม่มีข้อมูลแสดงเป็นกราฟ',
'Previous' => 'ก่อนหน้า',
'The id must be an integer' => 'ไอดีต้องเป็นตัวเลขจำนวนเต็ม',
@@ -547,8 +499,6 @@ return array(
'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' => 'แสดงสวิมเลนเริ่มต้น',
@@ -563,19 +513,9 @@ return array(
'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' => '',
+ 'Default categories for new projects (Comma-separated)' => 'ค่าเริ่มต้นหมวดสำหรับโปรเจคใหม่ (Comma-separated)',
'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.' => 'ผู้จัดการโปรเจคสามารถตั้งค่าของโปรเจคและมีสิทธิ์มากกว่าผู้ใช้ทั่วไป',
- // 'Gitlab Issue' => '',
'Subtask Id' => 'รหัสงานย่อย',
'Subtasks' => 'งานย่อย',
'Subtasks Export' => 'ส่งออก งานย่อย',
@@ -588,7 +528,7 @@ return array(
'All columns' => 'คอลัมน์ทั้งหมด',
'Calendar' => 'ปฏิทิน',
'Next' => 'ต่อไป',
- // '#%d' => '',
+ '#%d' => '#%d',
'All swimlanes' => 'สวิมเลนทั้งหมด',
'All colors' => 'สีทั้งหมด',
'Moved to column %s' => 'เคลื่อนไปคอลัมน์ %s',
@@ -601,20 +541,17 @@ return array(
'There is nothing to show.' => 'ไม่มีที่ต้องแสดง',
'Time Tracking' => 'ติดตามเวลา',
'You already have one subtask in progress' => 'คุณมีหนึ่งงานย่อยที่กำลังทำงาน',
- // 'Which parts of the project do you want to duplicate?' => '',
- // 'Disallow login form' => '',
- // 'Bitbucket commit received' => '',
- // 'Bitbucket webhooks' => '',
- // 'Help on Bitbucket webhooks' => '',
+ 'Which parts of the project do you want to duplicate?' => 'เลือกส่วนของโปรเจคที่คุณต้องการทำซ้ำ?',
+ 'Disallow login form' => 'ไม่อนุญาตให้ใช้แบบฟอร์มการเข้าสู่ระบบ',
'Start' => 'เริ่ม',
'End' => 'จบ',
'Task age in days' => 'อายุงาน',
'Days in this column' => 'วันในคอลัมน์นี้',
- // '%dd' => '',
+ '%dd' => '%d วัน',
'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?' => '',
+ 'Do you really want to remove this link with task #%d?' => 'คุณต้องการลบลิงค์นี้ของงาน #%d?',
'Field required' => 'ต้องใส่',
'Link added successfully.' => 'เพิ่มลิงค์เรียบร้อยแล้ว',
'Link updated successfully.' => 'ปรับปรุงลิงค์เรียบร้อยแล้ว',
@@ -625,7 +562,7 @@ return array(
'Link settings' => 'ตั้งค่าลิงค์',
'Opposite label' => 'ป้ายชื่อตรงข้าม',
'Remove a link' => 'ลบลิงค์',
- 'Task\'s links' => 'ลิงค',
+ 'Task\'s links' => 'ลิงค์',
'The labels must be different' => 'ป้ายชื่อต้องต่างกัน',
'There is no link.' => 'ไม่มีลิงค์',
'This label must be unique' => 'ป้ายชื่อต้องไม่ซ้ำกัน',
@@ -644,9 +581,8 @@ return array(
'fixes' => 'เจาะจง',
'is fixed by' => 'ถูกเจาะจงด้วย',
'This task' => 'งานนี้',
- // '<1h' => '',
- // '%dh' => '',
- // '%b %e' => '',
+ '<1h' => '<1 ชม.',
+ '%dh' => '%d ชม.',
'Expand tasks' => 'ขยายงาน',
'Collapse tasks' => 'ย่องาน',
'Expand/collapse tasks' => 'ขยาย/ย่อ งาน',
@@ -656,7 +592,6 @@ return array(
'Keyboard shortcuts' => 'คีย์ลัด',
'Open board switcher' => 'เปิดการสลับบอร์ด',
'Application' => 'แอพพลิเคชัน',
- 'since %B %e, %Y at %k:%M %p' => 'เริ่ม %B %e, %Y เวลา %k:%M %p',
'Compact view' => 'มุมมองพอดี',
'Horizontal scrolling' => 'เลื่อนตามแนวนอน',
'Compact/wide view' => 'พอดี/กว้าง มุมมอง',
@@ -665,18 +600,18 @@ return array(
'Files' => 'ไฟล์',
'Images' => 'รูปภาพ',
'Private project' => 'โปรเจคส่วนตัว',
- // 'AUD - Australian Dollar' => '',
- // 'CAD - Canadian Dollar' => '',
- // 'CHF - Swiss Francs' => '',
- // 'Custom Stylesheet' => '',
+ 'AUD - Australian Dollar' => 'AUD - ดอลลาร์ออสเตรเลีย',
+ 'CAD - Canadian Dollar' => 'CAD - ดอลลาร์แคนาดา',
+ 'CHF - Swiss Francs' => 'CHF - ฟรังก์สวิส',
+ 'Custom Stylesheet' => 'สไตล์ที่กำหนดเอง',
'download' => 'ดาวน์โหลด',
- // 'EUR - Euro' => '',
- // 'GBP - British Pound' => '',
- // 'INR - Indian Rupee' => '',
- // 'JPY - Japanese Yen' => '',
- // 'NZD - New Zealand Dollar' => '',
- // 'RSD - Serbian dinar' => '',
- // 'USD - US Dollar' => '',
+ 'EUR - Euro' => 'EUR - ยูโร',
+ 'GBP - British Pound' => 'GBP - ปอนด์อังกฤษ',
+ 'INR - Indian Rupee' => 'INR - รูปี',
+ 'JPY - Japanese Yen' => 'JPY - เยน',
+ 'NZD - New Zealand Dollar' => 'NZD - ดอลลาร์นิวซีแลนด์',
+ 'RSD - Serbian dinar' => 'RSD - ดีนาร์เซอร์เบีย',
+ 'USD - US Dollar' => 'USD - ดอลลาร์สหรัฐ',
'Destination column' => 'คอลัมน์เป้าหมาย',
'Move the task to another column when assigned to a user' => 'ย้ายงานไปคอลัมน์อื่นเมื่อกำหนดบุคคลรับผิดชอบ',
'Move the task to another column when assignee is cleared' => 'ย้ายงานไปคอลัมน์อื่นเมื่อไม่กำหนดบุคคลรับผิดชอบ',
@@ -686,16 +621,16 @@ return array(
'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' => 'อัตราแลกเปลี่ยน',
+ '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' => 'เพิ่มอัตราแลกเปลี่ยนเงินตราใหม่',
- // 'Reference currency' => '',
- // 'The currency rate have been added successfully.' => '',
- // 'Unable to add this currency rate.' => '',
+ 'Change reference currency' => 'เปลี่ยนการอ้างถึงค่าเงิน',
+ 'Add a new currency rate' => 'เพิ่มอัตราค่าเงินใหม่',
+ 'Reference currency' => 'อ้างถึงค่าเงิน',
+ 'The currency rate have been added successfully.' => 'เพิ่มอัตราค่าเงินเรียบร้อย',
+ 'Unable to add this currency rate.' => 'ไม่สามารถเพิ่มค่าเงินนี้',
// 'Webhook URL' => '',
- // '%s remove the assignee of the task %s' => '',
+ '%s remove the assignee of the task %s' => '%s เอาผู้รับผิดชอบออกจากงาน %s',
'Enable Gravatar images' => 'สามารถใช้งานภาพ Gravatar',
'Information' => 'ข้อมูลสารสนเทศ',
// 'Check two factor authentication code' => '',
@@ -703,56 +638,52 @@ return array(
// 'The two factor authentication code is valid.' => '',
'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' => 'กำหนดสีเมื่องานถูกย้ายไปคอลัมน์ที่กำหนดไว้',
// '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
+ 'uploaded by: %s' => 'อัพโหลดโดย: %s',
+ 'uploaded on: %s' => 'อัพโหลดบน: %s',
'size: %s' => 'ขนาด: %s',
'Burndown chart for "%s"' => 'แผนภูมิงานกับเวลา "%s"',
'Burndown chart' => 'แผนภูมิงานกับเวลา',
- // 'This chart show the task complexity over the time (Work Remaining).' => '',
- // 'Screenshot taken %s' => '',
+ 'This chart show the task complexity over the time (Work Remaining).' => 'แผนภูมิแสดงความซับซ้อนของงานตามเวลา (งานที่เหลือ)',
+ 'Screenshot taken %s' => 'จับภาพหน้าจอ %s',
'Add a screenshot' => 'เพิ่ม screenshot',
- // 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'จับภาพหน้าจอ (screenshot) และกด CTRL+V หรือ ⌘+V เพื่อวางที่นี้',
'Screenshot uploaded successfully.' => 'อัพโหลด screenshot เรียบร้อยแล้ว',
- // 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
- // 'Identifier' => '',
+ 'SEK - Swedish Krona' => 'SEK - โครนสวีเดน',
+ 'Identifier' => 'ตัวบ่งชี้',
// 'Disable two factor authentication' => '',
- // 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'คุณต้องการยกเลิก the two factor authentication สำหรับผู้ใช้นีั: "%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' => '',
+ '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: ' => '',
+ '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: ' => '',
+ '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: ' => '',
+ 'Timeframe to calculate new due date: ' => 'ระยะเวลาการคำนวณวันครบกำหนดใหม่: ',
'Trigger to generate recurrent task: ' => 'จะสร้างงานแบบวนลูป',
'When task is closed' => 'เมื่อปิดงาน',
'When task is moved from first column' => 'เมื่องานถูกย้ายจากคอลัมน์แรก',
@@ -774,294 +705,416 @@ return array(
// 'Two factor authentication enabled' => '',
'Unable to update this user.' => 'ไม่สามารถปรับปรุงผู้ใช้นี้',
'There is no user management for private projects.' => 'ไม่มีการจัดการผู้ใช้สำหรับโปรเจคส่วนตัว',
- // 'User that will receive the email' => '',
- // 'Email subject' => '',
- // 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
- // 'Add a comment log when moving the task between columns' => '',
- // 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
- // 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
- // 'Column change' => '',
- // 'Position change' => '',
- // 'Swimlane change' => '',
- // 'Assignee change' => '',
- // '[%s] Overdue tasks' => '',
- // 'Notification' => '',
- // '%s moved the task #%d to the first swimlane' => '',
- // '%s moved the task #%d to the swimlane "%s"' => '',
- // 'Swimlane' => '',
- // 'Gravatar' => '',
- // '%s moved the task %s to the first swimlane' => '',
- // '%s moved the task %s to the swimlane "%s"' => '',
- // 'This report contains all subtasks information for the given date range.' => '',
- // 'This report contains all tasks information for the given date range.' => '',
- // 'Project activities for %s' => '',
- // 'view the board on Kanboard' => '',
- // 'The task have been moved to the first swimlane' => '',
- // 'The task have been moved to another swimlane:' => '',
- // 'Overdue tasks for the project "%s"' => '',
- // 'New title: %s' => '',
- // 'The task is not assigned anymore' => '',
- // 'New assignee: %s' => '',
- // 'There is no category now' => '',
- // 'New category: %s' => '',
- // 'New color: %s' => '',
- // 'New complexity: %d' => '',
- // 'The due date have been removed' => '',
- // 'There is no description anymore' => '',
- // 'Recurrence settings have been modified' => '',
- // 'Time spent changed: %sh' => '',
- // 'Time estimated changed: %sh' => '',
- // 'The field "%s" have been updated' => '',
- // 'The description have been modified' => '',
- // 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
- // 'I want to receive notifications for:' => '',
- // 'All tasks' => '',
- // 'Only for tasks assigned to me' => '',
- // 'Only for tasks created by me' => '',
- // 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
+ 'User that will receive the email' => 'ผู้ใช้จะได้รับอีเมล์',
+ 'Email subject' => 'หัวเรื่องอีเมล์',
+ 'Date' => 'วันที่',
+ 'Add a comment log when moving the task between columns' => 'เพิ่มล็อกความคิดเห็นเมื่อย้ายงานระหว่างคอลัมน์',
+ 'Move the task to another column when the category is changed' => 'ย้ายงานไปคอลัมน์อื่นเมื่อหมวดถูกเปลี่ยน',
+ 'Send a task by email to someone' => 'ส่งงานโดยถึงบางคน',
+ 'Reopen a task' => 'เปิดงานอีกครั้ง',
+ 'Column change' => 'เปลี่ยนคอลัมน์',
+ 'Position change' => 'เปลี่ยนตำแหน่ง',
+ 'Swimlane change' => 'เปลี่ยนสวิมเลน',
+ 'Assignee change' => 'เปลี่ยนการผู้รับผิดชอบ',
+ '[%s] Overdue tasks' => '[%s] งานที่เกินกำหนด',
+ 'Notification' => 'แจ้งเตือน',
+ '%s moved the task #%d to the first swimlane' => '%s ย้ายงาน #%d ไปสวินเลนแรก',
+ '%s moved the task #%d to the swimlane "%s"' => '%s ย้ายงาน #%d ไปสวินเลน "%s"',
+ 'Swimlane' => 'สวิมเลน',
+ 'Gravatar' => 'รูปแทนตัว',
+ '%s moved the task %s to the first swimlane' => '%s ย้ายงาน %s ไปสวินเลนแรก',
+ '%s moved the task %s to the swimlane "%s"' => '%s ย้ายงาน %s ไปสวินเลนไปสวินเลน "%s"',
+ 'This report contains all subtasks information for the given date range.' => 'รายงานนี้มีข้อมูลงานย่อยทั้งหมดในช่วงวันที่กำหนด',
+ 'This report contains all tasks information for the given date range.' => 'รายงานนี้มีข้อมูลงานทั้งหมดสำหรับช่วงวันที่ที่กำหนด',
+ 'Project activities for %s' => 'กิจกรรมโปรเจคสำหรับ %s',
+ 'view the board on Kanboard' => 'แสดงบอร์ดบนคังบอร์ด',
+ 'The task have been moved to the first swimlane' => 'งานถูกย้านไปสวิมเลนแรก',
+ 'The task have been moved to another swimlane:' => 'งานถูกย้านไปสวิมเลนอื่น:',
+ 'Overdue tasks for the project "%s"' => 'งานที่เกินกำหนดสำหรับโปรเจค "%s"',
+ 'New title: %s' => 'ชื่อเรื่องใหม่: %s',
+ 'The task is not assigned anymore' => 'ไม่กำหนดผู้รับผิดชอบ',
+ 'New assignee: %s' => 'ผู้รับผิดชอบใหม่: %s',
+ 'There is no category now' => 'ปัจจุบันไม่มีหมวด',
+ 'New category: %s' => 'หมวดใหม่: %s',
+ 'New color: %s' => 'สีใหม่: %s',
+ 'New complexity: %d' => 'ความซับซ้อนใหม่: %d',
+ 'The due date have been removed' => 'วันครบกำหนดถูกลบ',
+ 'There is no description anymore' => 'ไม่มีคำอธิบาย',
+ 'Recurrence settings have been modified' => 'แก้ไขการตั้งค่าการวนลูป',
+ 'Time spent changed: %sh' => 'เวลาที่ใช้ในการเปลี่ยน: %s ชม.',
+ 'Time estimated changed: %sh' => 'เวลาโดยประมาณในการเปลี่ยน: %s ชม.',
+ 'The field "%s" have been updated' => 'ฟิลด์ "%s" ถูกปรับปรุง',
+ 'The description have been modified' => 'คำอธิบายถูกแก้ไข',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'คุณต้องการปิดงาน "%s" เช่นเดียวกับงานย่อยทั้งหมด?',
+ 'I want to receive notifications for:' => 'ฉันต้องการรับการแจ้งเตือนสำหรับ:',
+ 'All tasks' => 'ทุกงาน',
+ 'Only for tasks assigned to me' => 'เฉพาะงานที่ฉันรับผิดชอบ',
+ 'Only for tasks created by me' => 'เฉพาะงานที่ฉันสร้าง',
+ 'Only for tasks created by me and assigned to me' => 'เฉพาะงานที่ฉันสร้างและฉันรับผิดชอบ',
// '%%Y-%%m-%%d' => '',
- // 'Total for all columns' => '',
- // 'You need at least 2 days of data to show the chart.' => '',
- // '<15m' => '',
- // '<30m' => '',
- // 'Stop timer' => '',
- // 'Start timer' => '',
- // 'Add project member' => '',
- // 'Enable notifications' => '',
- // 'My activity stream' => '',
- // 'My calendar' => '',
- // 'Search tasks' => '',
- // 'Back to the calendar' => '',
- // 'Filters' => '',
- // 'Reset filters' => '',
- // 'My tasks due tomorrow' => '',
- // 'Tasks due today' => '',
- // 'Tasks due tomorrow' => '',
- // 'Tasks due yesterday' => '',
- // 'Closed tasks' => '',
- // 'Open tasks' => '',
- // 'Not assigned' => '',
- // 'View advanced search syntax' => '',
- // 'Overview' => '',
- // '%b %e %Y' => '',
- // 'Board/Calendar/List view' => '',
- // 'Switch to the board view' => '',
- // 'Switch to the calendar view' => '',
- // 'Switch to the list view' => '',
- // 'Go to the search/filter box' => '',
- // 'There is no activity yet.' => '',
- // 'No tasks found.' => '',
- // 'Keyboard shortcut: "%s"' => '',
- // 'List' => '',
- // 'Filter' => '',
- // 'Advanced search' => '',
- // 'Example of query: ' => '',
- // 'Search by project: ' => '',
- // 'Search by column: ' => '',
- // 'Search by assignee: ' => '',
- // 'Search by color: ' => '',
- // 'Search by category: ' => '',
- // 'Search by description: ' => '',
- // 'Search by due date: ' => '',
- // 'Lead and Cycle time for "%s"' => '',
- // 'Average time spent into each column for "%s"' => '',
- // 'Average time spent into each column' => '',
- // 'Average time spent' => '',
- // 'This chart show the average time spent into each column for the last %d tasks.' => '',
- // 'Average Lead and Cycle time' => '',
- // 'Average lead time: ' => '',
- // 'Average cycle time: ' => '',
- // 'Cycle Time' => '',
- // 'Lead Time' => '',
- // 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
- // 'Average time into each column' => '',
- // 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
- // 'Lead time: ' => '',
- // 'Cycle time: ' => '',
- // 'Time spent into each column' => '',
- // 'The lead time is the duration between the task creation and the completion.' => '',
- // 'The cycle time is the duration between the start date and the completion.' => '',
+ 'Total for all columns' => 'จำนวนคอลัมน์ทั้งหมด',
+ 'You need at least 2 days of data to show the chart.' => 'คุณต้องการอย่างน้อย 2 วันในการแสดงแผนภูมิ',
+ '<15m' => '<15นาที',
+ '<30m' => '<30นาที',
+ 'Stop timer' => 'หยุดจับเวลา',
+ 'Start timer' => 'เริ่มจับเวลา',
+ 'Add project member' => 'เพิ่มสมาชิกโปรเจค',
+ 'Enable notifications' => 'เปิดการแจ้งเตือน',
+ 'My activity stream' => 'กิจกรรมที่เกิดขึ้นของฉัน',
+ 'My calendar' => 'ปฎิทินของฉัน',
+ 'Search tasks' => 'ค้นหางาน',
+ 'Back to the calendar' => 'กลับไปที่ปฎิทิน',
+ 'Filters' => 'ตัวกรอง',
+ 'Reset filters' => 'ล้างตัวกรอง',
+ 'My tasks due tomorrow' => 'งานถึงกำหนดของฉันวันพรุ่งนี้',
+ 'Tasks due today' => 'งานถึงกำหนดวันนี้',
+ 'Tasks due tomorrow' => 'งานถึงกำหนดพรุ่งนี้',
+ 'Tasks due yesterday' => 'งานถึงกำหนดเมื่อวาน',
+ 'Closed tasks' => 'งานปิด',
+ 'Open tasks' => 'งานเปิด',
+ 'Not assigned' => 'ไม่กำหนดใคร',
+ 'View advanced search syntax' => 'แสดงรูปแบบการค้นหาขั้นสูง',
+ 'Overview' => 'ภาพรวม',
+ 'Board/Calendar/List view' => 'มุมมอง บอร์ด/ปฎิทิน/ลิสต์',
+ 'Switch to the board view' => 'เปลี่ยนเป็นมุมมองบอร์ด',
+ 'Switch to the calendar view' => 'เปลี่ยนเป็นมุมมองปฎิทิน',
+ 'Switch to the list view' => 'เปลี่ยนเป็นมุมมองลิสต์',
+ 'Go to the search/filter box' => 'ไปที่กล่องค้นหา/ตัวกรอง',
+ 'There is no activity yet.' => 'ตอนนี้ไม่มีกิจกรรม',
+ 'No tasks found.' => 'ไม่พบงาน',
+ 'Keyboard shortcut: "%s"' => 'คีย์ลัด: %s',
+ 'List' => 'ลิสต์',
+ 'Filter' => 'ตัวกรอง',
+ 'Advanced search' => 'ค้นหาขั้นสูง',
+ 'Example of query: ' => 'ตัวอย่างคิวรี: ',
+ 'Search by project: ' => 'ค้นหาตามโปรเจค: ',
+ 'Search by column: ' => 'ค้นหาตามคอลัมน์: ',
+ 'Search by assignee: ' => 'ค้นหาตามผู้รับผิดชอบ: ',
+ 'Search by color: ' => 'ค้นหาตามสี: ',
+ 'Search by category: ' => 'ค้นหาตามหมวด: ',
+ 'Search by description: ' => 'ค้นหาตามคำอธิบาย: ',
+ 'Search by due date: ' => 'ค้นหาตามวันครบกำหนด: ',
+ 'Lead and Cycle time for "%s"' => 'เวลานำและรอบเวลาสำหรับ "%s"',
+ 'Average time spent into each column for "%s"' => 'ค่าเฉลี่ยเวลาที่ใช้แต่ละคอลัมน์สำหรับ "%s"',
+ 'Average time spent into each column' => 'ค่าเฉลี่ยเวลาที่ใช้แต่ละคอลัมน์',
+ 'Average time spent' => 'ค่าเฉลี่ยเวลาที่ใช้',
+ 'This chart show the average time spent into each column for the last %d tasks.' => 'แผนภูมิแสดงค่าเฉลี่ยเวลาที่ใช้แต่ละคอลัมน์สำหรับ %d งานล่าสุด',
+ 'Average Lead and Cycle time' => 'ค่าเฉลี่ยเวลานำและรอบเวลา',
+ 'Average lead time: ' => 'ค่าเฉลี่ยเวลานำ: ',
+ 'Average cycle time: ' => 'ค่าเฉลี่ยรอบเวลา: ',
+ 'Cycle Time' => 'รอบเวลา',
+ 'Lead Time' => 'เวลานำ',
+ 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'แผนภูมิแสดงค่าเฉลี่ยเวลานำและรอบเวลาสำหรับ %d งานล่าสุดเมื่อเวลาผ่านไป',
+ 'Average time into each column' => 'ค่าเฉลี่ยเวลาแต่ละคอลัมน์',
+ 'Lead and cycle time' => 'เวลานำและรอบเวลา',
+ 'Lead time: ' => 'เวลานำ: ',
+ 'Cycle time: ' => 'รอบเวลา: ',
+ 'Time spent into each column' => 'เวลาที่ใช้แต่ละคอลัมน์',
+ 'The lead time is the duration between the task creation and the completion.' => 'เวลานำคือระหว่างสร้างงานและจบงาน',
+ 'The cycle time is the duration between the start date and the completion.' => 'รอบเวลาคือระหว่างวันที่เริ่มและจบงาน',
// 'If the task is not closed the current time is used instead of the completion date.' => '',
- // 'Set automatically the start date' => '',
- // 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
- // 'Remote user' => '',
+ 'Set automatically the start date' => 'ตั้งค่าวันที่เริ่มต้นอัตโนมัติ',
+ 'Edit Authentication' => 'แก้ไขการตรวจสอบ',
+ 'Remote user' => 'ผู้ใช้รีโมท',
// 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
// 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
- // 'New remote user' => '',
- // 'New local user' => '',
- // 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
- // 'This feature does not work with all browsers.' => '',
+ 'New remote user' => 'เพิ่มผู้ใช้รีโมทใหม่',
+ 'New local user' => 'เพิ่มผู้ใช้ท้องถิ่นใหม่',
+ 'Default task color' => 'สีเริ่มต้นของงาน',
+ 'This feature does not work with all browsers.' => 'คุณลักษณะนี้ไม่สามารถทำงานได้ทุกเบราเซอร์',
// 'There is no destination project available.' => '',
- // 'Trigger automatically subtask time tracking' => '',
+ 'Trigger automatically subtask time tracking' => 'เรียกโดยอัตโนมัติการติดตามเวลางานย่อย',
// 'Include closed tasks in the cumulative flow diagram' => '',
- // 'Current swimlane: %s' => '',
- // 'Current column: %s' => '',
- // 'Current category: %s' => '',
- // 'no category' => '',
- // 'Current assignee: %s' => '',
- // 'not assigned' => '',
- // 'Author:' => '',
- // 'contributors' => '',
- // 'License:' => '',
- // 'License' => '',
- // 'Project Administrator' => '',
- // 'Enter the text below' => '',
- // 'Gantt chart for %s' => '',
- // 'Sort by position' => '',
- // 'Sort by date' => '',
- // 'Add task' => '',
- // 'Start date:' => '',
- // 'Due date:' => '',
- // 'There is no start date or due date for this task.' => '',
- // 'Moving or resizing a task will change the start and due date of the task.' => '',
- // 'There is no task in your project.' => '',
- // 'Gantt chart' => '',
- // 'People who are project managers' => '',
- // 'People who are project members' => '',
- // 'NOK - Norwegian Krone' => '',
- // 'Show this column' => '',
- // 'Hide this column' => '',
- // 'open file' => '',
- // 'End date' => '',
- // 'Users overview' => '',
- // 'Managers' => '',
- // 'Members' => '',
- // 'Shared project' => '',
- // 'Project managers' => '',
- // 'Project members' => '',
- // 'Gantt chart for all projects' => '',
- // 'Projects list' => '',
- // 'Gantt chart for this project' => '',
- // 'Project board' => '',
- // 'End date:' => '',
- // 'There is no start date or end date for this project.' => '',
- // 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
- // 'Link type' => '',
- // 'Change task color when using a specific task link' => '',
- // 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
- // 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
- // 'Documentation: %s' => '',
- // 'Switch to the Gantt chart view' => '',
- // 'Reset the search/filter box' => '',
- // 'Documentation' => '',
- // 'Table of contents' => '',
- // 'Gantt' => '',
- // 'Help with project permissions' => '',
- // 'Author' => '',
- // 'Version' => '',
- // 'Plugins' => '',
- // 'There is no plugin loaded.' => '',
- // 'Set maximum column height' => '',
- // 'Remove maximum column height' => '',
- // 'My notifications' => '',
- // 'Custom filters' => '',
- // 'Your custom filter have been created successfully.' => '',
- // 'Unable to create your custom filter.' => '',
- // 'Custom filter removed successfully.' => '',
- // 'Unable to remove this custom filter.' => '',
- // 'Edit custom filter' => '',
- // 'Your custom filter have been updated successfully.' => '',
- // 'Unable to update custom filter.' => '',
- // 'Web' => '',
- // 'New attachment on task #%d: %s' => '',
- // 'New comment on task #%d' => '',
- // 'Comment updated on task #%d' => '',
- // 'New subtask on task #%d' => '',
- // 'Subtask updated on task #%d' => '',
- // 'New task #%d: %s' => '',
- // 'Task updated #%d' => '',
- // 'Task #%d closed' => '',
- // 'Task #%d opened' => '',
- // 'Column changed for task #%d' => '',
- // 'New position for task #%d' => '',
- // 'Swimlane changed for task #%d' => '',
- // 'Assignee changed on task #%d' => '',
- // '%d overdue tasks' => '',
- // 'Task #%d is overdue' => '',
- // 'No new notifications.' => '',
- // 'Mark all as read' => '',
- // 'Mark as read' => '',
+ 'Current swimlane: %s' => 'สวิมเลนปัจจุบัน: %s',
+ 'Current column: %s' => 'คอลัมน์ปัจจุบัน: %s',
+ 'Current category: %s' => 'หมวดปัจจุบัน: %s',
+ 'no category' => 'ไม่มีหมวด',
+ 'Current assignee: %s' => 'ผู้รับผิดชอบปัจจุบัน: %s',
+ 'not assigned' => 'ไม่กำหนด',
+ 'Author:' => 'ผู้แต่ง:',
+ 'contributors' => 'ผู้ให้กำเนิด',
+ 'License:' => 'สัญญาอนุญาต:',
+ 'License' => 'สัญญาอนุญาต',
+ 'Enter the text below' => 'พิมพ์ข้อความด้านล่าง',
+ 'Gantt chart for %s' => 'แผนภูมิแกรนท์สำหรับ %s',
+ 'Sort by position' => 'เรียงตามตำแหน่ง',
+ 'Sort by date' => 'เรียงตามวัน',
+ 'Add task' => 'เพิ่มงาน',
+ 'Start date:' => 'วันที่เริ่ม:',
+ 'Due date:' => 'วันครบกำหนด:',
+ 'There is no start date or due date for this task.' => 'งานนี้ไม่มีวันที่เริ่มหรือวันครบกำหนด',
+ 'Moving or resizing a task will change the start and due date of the task.' => 'การย้ายหรือปรับขนาดงานจะมีการเปลี่ยนแปลงที่วันเริ่มต้นและวันที่ครบกำหนดของงาน',
+ 'There is no task in your project.' => 'โปรเจคนี้ไม่มีงาน',
+ 'Gantt chart' => 'แผนภูมิแกรนท์',
+ 'People who are project managers' => 'คนที่เป็นผู้จัดการโปรเจค',
+ 'People who are project members' => 'คนที่เป็นสมาชิกโปรเจค',
+ 'NOK - Norwegian Krone' => 'NOK - โครนนอร์เวย์',
+ 'Show this column' => 'แสดงคอลัมนี้',
+ 'Hide this column' => 'ซ่อนคอลัมน์นี้',
+ 'open file' => 'เปิดไฟล์',
+ 'End date' => 'วันจบ',
+ 'Users overview' => 'ภาพรวมผู้ใช้',
+ 'Members' => 'สมาชิก',
+ 'Shared project' => 'แชร์โปรเจค',
+ 'Project managers' => 'ผู้จัดการโปรเจค',
+ 'Gantt chart for all projects' => 'แผนภูมิแกรนท์สำหรับทุกโปรเจค',
+ 'Projects list' => 'รายการโปรเจค',
+ 'Gantt chart for this project' => 'แผนภูมิแกรนท์สำหรับโปรเจคนี้',
+ 'Project board' => 'บอร์ดโปรเจค',
+ 'End date:' => 'วันที่จบ:',
+ 'There is no start date or end date for this project.' => 'ไม่มีวันที่เริ่มหรือวันที่จบของโปรเจคนี้',
+ 'Projects Gantt chart' => 'แผนภูมิแกรน์ของโปรเจค',
+ 'Link type' => 'ประเภทลิงค์',
+ 'Change task color when using a specific task link' => 'เปลี่ยนสีงานเมื่อมีการใช้การเชื่อมโยงงาน',
+ 'Task link creation or modification' => 'การสร้างการเชื่อมโยงงานหรือการปรับเปลี่ยน',
+ 'Milestone' => 'ขั้น',
+ 'Documentation: %s' => 'เอกสาร: %s',
+ 'Switch to the Gantt chart view' => 'เปลี่ยนเป็นมุมมองแผนภูมิแกรนท์',
+ 'Reset the search/filter box' => 'รีเซตกล่องค้นหา/ตัวกรอง',
+ 'Documentation' => 'เอกสาร',
+ 'Table of contents' => 'สารบัญ',
+ 'Gantt' => 'แกรนท์',
+ 'Author' => 'ผู้แต่ง',
+ 'Version' => 'เวอร์ชัน',
+ 'Plugins' => 'ปลั๊กอิน',
+ 'There is no plugin loaded.' => 'ไม่มีปลั๊กอินถูกโหลดไว้',
+ 'Set maximum column height' => 'กำหนดความสูงสูงสุดของคอลัมน์',
+ 'Remove maximum column height' => 'เอาความสูงสูงสุดของคอลัมน์ออก',
+ 'My notifications' => 'การแจ้งเตือนของฉัน',
+ 'Custom filters' => 'ตัวกรองกำหนดเอง',
+ 'Your custom filter have been created successfully.' => 'ตัวกรองกำหนดเองของคุณสร้างเรียบร้อย',
+ 'Unable to create your custom filter.' => 'ไม่สามารถสร้างตัวกรองกำหนดเอง',
+ 'Custom filter removed successfully.' => 'ลบตัวกรองกำหนดเองเรียบร้อย',
+ 'Unable to remove this custom filter.' => 'ไม่สามารถลบตัวกรองกำหนดเอง',
+ 'Edit custom filter' => 'แก้ไขตัวกรองกำหนดเอง',
+ 'Your custom filter have been updated successfully.' => 'ตัวกรองกำหนดเองของคุณแก้ไขเรียบร้อย',
+ 'Unable to update custom filter.' => 'ไม่สามารถแก้ไขตัวกรองกำหนดเอง',
+ 'Web' => 'เวบ',
+ 'New attachment on task #%d: %s' => 'แนบใหม่ของงาน #%d: %s',
+ 'New comment on task #%d' => 'ความคิดเห็นใหม่ของงาน #%d',
+ 'Comment updated on task #%d' => 'แก้ไขความคิดเห็นของงาน #%d',
+ 'New subtask on task #%d' => 'งานย่อยใหม่ของงาน #%d',
+ 'Subtask updated on task #%d' => 'แก้ไขงานย่อยของงาน #%d',
+ 'New task #%d: %s' => 'งานใหม่ #%d: %s',
+ 'Task updated #%d' => 'แก้ไขงาน #%d',
+ 'Task #%d closed' => 'ปิดงาน #%d',
+ 'Task #%d opened' => 'เปิดงาน #%d',
+ 'Column changed for task #%d' => 'เปลี่ยนคอลัมน์สำหรับงาน #%d',
+ 'New position for task #%d' => 'ตำแหน่งใหม่ของงาน #%d',
+ 'Swimlane changed for task #%d' => 'เปลี่ยนสวิมเลนสำหรับงาน #%d',
+ 'Assignee changed on task #%d' => 'เปลี่ยนผู้รับผิดชอบงาน #%d',
+ '%d overdue tasks' => '%d งานเกินกำหนด',
+ 'Task #%d is overdue' => 'งาน #%d เกินกำหนด',
+ 'No new notifications.' => 'ไม่มีการแจ้งเตือนใหม่',
+ 'Mark all as read' => 'มาร์คทั้งหมดว่าอ่านแล้ว',
+ 'Mark as read' => 'มาร์คว่าอ่านแล้ว',
// 'Total number of tasks in this column across all swimlanes' => '',
- // 'Collapse swimlane' => '',
- // 'Expand swimlane' => '',
- // 'Add a new filter' => '',
- // 'Share with all project members' => '',
- // 'Shared' => '',
- // 'Owner' => '',
- // 'Unread notifications' => '',
- // 'My filters' => '',
- // 'Notification methods:' => '',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
+ 'Collapse swimlane' => 'ย่อสวิมเลน',
+ 'Expand swimlane' => 'ขยายสวิมเลน',
+ 'Add a new filter' => 'เพิ่มตัวกรองใหม่',
+ 'Share with all project members' => 'แชร์ให้สมาชิกทุกคนของโปรเจค',
+ 'Shared' => 'แชร์',
+ 'Owner' => 'เจ้าของ',
+ 'Unread notifications' => 'การแจ้งเตือนยังไม่ได้อ่าน',
+ 'My filters' => 'ตัวกรองของฉัน',
+ 'Notification methods:' => 'ลักษณะการแจ้งเตือน:',
+ 'Import tasks from CSV file' => 'นำเข้างานจากไฟล์ CSV',
+ 'Unable to read your file' => 'ไม่สามารถอ่านไฟล์ของคุณ',
+ '%d task(s) have been imported successfully.' => '%d งานนำเข้าเรียบร้อย',
+ 'Nothing have been imported!' => 'ไม่มีอะไรถูกนำเข้า',
+ 'Import users from CSV file' => 'นำเข้าผู้ใช้จากไฟล์ CSV',
+ '%d user(s) have been imported successfully.' => '%d ผู้ใช้นำเข้าเรียบร้อย',
+ 'Comma' => ', - Comma',
+ 'Semi-colon' => '; - Semi-colon',
+ 'Tab' => 'Tab - Tab',
+ 'Vertical bar' => '| - Vertical bar',
+ 'Double Quote' => '" " - Double Quote',
+ 'Single Quote' => '\' \' - Single Quote',
+ '%s attached a file to the task #%d' => '%s แนบไฟล์ในงาน #%d',
+ 'There is no column or swimlane activated in your project!' => 'ไม่มีคอลัมน์หรือสวิมเลนเปิดใช้งานในโปรเจคของคุณ!',
// 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
+ 'Append/Replace' => 'เพิ่มเติม/แทนที่',
+ 'Append' => 'เพิ่มเติม',
+ 'Replace' => 'แทนที่',
+ 'Import' => 'นำเข้า',
+ 'change sorting' => 'เปลี่ยนการเรียง',
+ 'Tasks Importation' => 'การนำเข้างาน',
+ 'Delimiter' => 'คั่น',
+ 'Enclosure' => 'กำหนดข้อความ',
+ 'CSV File' => 'ไฟล์ CSV',
+ 'Instructions' => 'คำสั่ง',
+ 'Your file must use the predefined CSV format' => 'ไฟล์ของคุณจะต้องใช้รูปแบบ CSV ที่กำหนดไว้ล่วงหน้า',
+ 'Your file must be encoded in UTF-8' => 'ไฟล์ของคุณต้องเอนโค้ดด้วย UTF-8',
+ 'The first row must be the header' => 'แถวแรกต้องเป็นหัวข้อ',
+ 'Duplicates are not verified for you' => 'รายการที่ซ้ำกันจะไม่ได้รับการตรวจสอบสำหรับคุณ',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'วันที่ต้องอยู่ในรูปแบบ ISO: YYYY-MM-DD',
+ 'Download CSV template' => 'ดาวน์โหลด CSV ต้นฉบับ',
+ 'No external integration registered.' => 'ไม่มีการรวมภายนอกถูกลงทะเบียนไว้',
+ 'Duplicates are not imported' => 'ซ้ำกันไม่สามารถนำเข้าได้',
+ 'Usernames must be lowercase and unique' => 'ชื่อผู้ใช้ต้องเป็นตัวพิมพ์เล็กและไม่ซ้ำ',
// 'Passwords will be encrypted if present' => '',
+ '%s attached a new file to the task %s' => '%s แนบไฟล์ใหม่ในงาน %s',
+ 'Assign automatically a category based on a link' => 'กำหนดหมวดอัตโนมัติตามลิงค์',
+ // 'BAM - Konvertible Mark' => '',
+ 'Assignee Username' => 'กำหนดชื่อผู้ใช้',
+ 'Assignee Name' => 'กำหนดชื่อ',
+ 'Groups' => 'กลุ่ม',
+ 'Members of %s' => 'สมาชิกของ %s',
+ 'New group' => 'กลุ่มใหม่',
+ 'Group created successfully.' => 'สร้างกลุ่มสำเร็จ',
+ 'Unable to create your group.' => 'ไม่สามารถสร้างกลุ่มของคุณ',
+ 'Edit group' => 'แก้ไขกลุ่ม',
+ 'Group updated successfully.' => 'แก้ไขกลุ่มเรียบร้อย',
+ 'Unable to update your group.' => 'ไม่สามารถแก้ไขกลุ่มของคุณ',
+ 'Add group member to "%s"' => 'เพิ่มสมาชิกในกลุ่ม %s',
+ 'Group member added successfully.' => 'เพิ่มสมาชิกกลุ่มเรียบร้อย',
+ 'Unable to add group member.' => 'ไม่สามารถเพิ่มสมาชิกกลุ่ม',
+ 'Remove user from group "%s"' => 'เอาผู้ใช้ออกจากกลุ่ม %s',
+ 'User removed successfully from this group.' => 'เอาผู้ใช้ออกจากกลุ่มนี้เรียบร้อย',
+ 'Unable to remove this user from the group.' => 'ไม่สามารถลบผู้ใช้ออกจากกลุ่มนี้',
+ 'Remove group' => 'ลบกลุ่ม',
+ 'Group removed successfully.' => 'ลบกลุ่มเรียบร้อย',
+ 'Unable to remove this group.' => 'ไม่สามารถลบกลุ่มนี้',
+ 'Project Permissions' => 'การอนุญาตใช้งานโปรเจค',
+ 'Manager' => 'ผู้จัดการ',
+ 'Project Manager' => 'ผู้จัดการโปรเจค',
+ 'Project Member' => 'สมาชิกโปรเจค',
+ 'Project Viewer' => 'ผู้ดูโปรเจค',
+ 'Your account is locked for %d minutes' => 'บัญชีของคุณถูกล็อก %d นาที',
+ 'Invalid captcha' => 'captcha ไม่ถูกต้อง',
+ 'The name must be unique' => 'ชื่อต้องไม่ซ้ำ',
+ 'View all groups' => 'แสดงกลุ่มทั้งหมด',
+ 'View group members' => 'แสดงสมาชิกกลุ่ม',
+ // 'There is no user available.' => '',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => 'คุณต้องการลบผู้ใช้ "%s" ออกจากกลุ่ม "%s"?',
+ 'There is no group.' => 'ไม่มีกลุ่ม',
+ 'External Id' => 'ไอดีภายนอก',
+ 'Add group member' => 'เพิ่มสมาชิกกลุ่ม',
+ 'Do you really want to remove this group: "%s"?' => 'คุณต้องการลบกลุ่มนี้: "%s"?',
+ 'There is no user in this group.' => 'ไม่มีผู้ใช้ในกลุ่มนี้',
+ 'Remove this user' => 'เอาผู้ใช้คนนี้ออก',
+ 'Permissions' => 'การอนุญาตใช้งาน',
+ 'Allowed Users' => 'การอนุญาตผู้ใช้',
+ 'No user have been allowed specifically.' => 'ไม่มีผู้ใช้ได้รับอนุญาติเป็นพิเศษ',
+ 'Role' => 'บทบาท',
+ 'Enter user name...' => 'พิมพ์ชื่อผู้ใช้...',
+ 'Allowed Groups' => 'อนุญาตกลุ่ม',
+ 'No group have been allowed specifically.' => 'ไม่มีกลุ่มได้รับอนุญาติเป็นพิเศษ',
+ 'Group' => 'กลุ่ม',
+ 'Group Name' => 'ชื่อกลุ่ม',
+ 'Enter group name...' => 'พิมพ์ชื่อกลุ่ม...',
+ 'Role:' => 'บทบาท:',
+ 'Project members' => 'สมาชิกโปรเจค',
+ 'Compare hours for "%s"' => 'เปรียบเทียบรายชั่วโมงสำหรับ %s',
+ '%s mentioned you in the task #%d' => '%s กล่าวถึงคุณในงาน #%d',
+ '%s mentioned you in a comment on the task #%d' => '%s กล่าวถึงคุณในความคิดเห็นของงาน #%d',
+ 'You were mentioned in the task #%d' => 'คุณได้รับการกล่าวถึงในงาน #%d',
+ 'You were mentioned in a comment on the task #%d' => 'คุณได้รับการกล่าวถึงในความคิดเห็นของงาน #%d',
+ 'Mentioned' => 'กล่าวถึง',
+ 'Compare Estimated Time vs Actual Time' => 'เปรียบเทียบเวลาโดยประมาณกับเวลาที่เกิดขึ้นจริง',
+ 'Estimated hours: ' => 'เวลาโดยประมาณ:',
+ 'Actual hours: ' => 'เวลาที่เกิดขึ้นจริง:',
+ 'Hours Spent' => 'เวลาที่ใช้',
+ 'Hours Estimated' => 'เวลาโดยประมาณ',
+ 'Estimated Time' => 'เวลาโดยประมาณ',
+ 'Actual Time' => 'เวลาที่ใช้',
+ 'Estimated vs actual time' => 'เวลาโดยประมาณกับเวลาจริง',
+ 'RUB - Russian Ruble' => 'RUB - รูเบิลรัสเซีย',
+ 'Assign the task to the person who does the action when the column is changed' => 'กำหนดผู้รับผิดชอบงานเมื่อเปลี่ยนคอลัมน์',
+ 'Close a task in a specific column' => 'ปิดงานในคอลัมน์ที่เฉพาะเจาะจง',
+ // 'Time-based One-time Password Algorithm' => '',
+ // 'Two-Factor Provider: ' => '',
+ // 'Disable two-factor authentication' => '',
+ // 'Enable two-factor authentication' => '',
+ // 'There is no integration registered at the moment.' => '',
+ 'Password Reset for Kanboard' => 'รีเซตรหัสผ่านสำหรับคังบอร์ด',
+ 'Forgot password?' => 'ลืมรหัสผ่าน?',
+ 'Enable "Forget Password"' => 'เปิดการใช้งาน "ลืมรหัสผ่าน"',
+ 'Password Reset' => 'รีเซตรหัสผ่าน',
+ 'New password' => 'รหัสผ่านใหม่',
+ 'Change Password' => 'เปลี่ยนรหัสผ่าน',
+ 'To reset your password click on this link:' => 'ในการรีเซตรหัสผ่านของคุณคลิ๊กที่ลิงค์นี้:',
+ 'Last Password Reset' => 'รีเซตรหัสผ่านครั้งล่าสุด',
+ 'The password has never been reinitialized.' => 'รหัสผ่านไม่เคยเริ่มใหม่อีกครั้ง',
+ 'Creation' => 'สร้าง',
+ 'Expiration' => 'สิ้นสุด',
+ 'Password reset history' => 'ประวัติการรีเซตรหัสผ่าน',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'ทุกงานของคอลัมน์ "%s" และสวิมเลน "%s" ถูกปิดเรียบร้อย',
+ 'Do you really want to close all tasks of this column?' => 'คุณต้องการปิดทุกงานในคอลัมนี้ใช่หรือไม่?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d งานในคอลัมน์ "%s" และสวิมเลน "%s" จะปิด',
+ 'Close all tasks of this column' => 'ปิดทุกงานในคอลัมน์นี้',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'ปลั๊กอินไม่ได้ลงทะเบียนการแจ้งเตือนในโปรเจค คุณยังสามารถกำหนดค่าการแจ้งเตือนรายบุคคลในโปรไฟล์ผู้ใช้ของคุณ',
+ 'My dashboard' => 'แดชบอร์ดของฉํน',
+ 'My profile' => 'โปรเจคของฉัน',
+ 'Project owner: ' => 'เจ้าของโปรเจค: ',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'ตัวบ่งชี้โปรโจคเป็นตัวเลือกเสริมและต้องเป็นตัวอักษรหรือตัวเลข ตัวอย่าง: MYPROJECT',
+ 'Project owner' => 'เจ้าของโปรเจค',
+ 'Those dates are useful for the project Gantt chart.' => 'วันที่ใช้สำหรับแผนภูมิแกรนท์ของโปรเจค',
+ 'Private projects do not have users and groups management.' => 'โปรเจคส่วนตัวไม่มีการจัดการผู้ใช้และกลุ่ม',
+ 'There is no project member.' => 'ไม่มีสมาชิกโปรเจค',
+ 'Priority' => 'ความสำคัญ',
+ 'Task priority' => 'ความสำคัญของงาน',
+ 'General' => 'ทั่วไป',
+ 'Dates' => 'วันที่',
+ 'Default priority' => 'ความสำคัญเริ่มต้น',
+ 'Lowest priority' => 'ความสำคัญต่ำสุด',
+ 'Highest priority' => 'ความสำคัญสูงสุด',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => 'ถ้าคุณใส่เลขศูนย์ทั้งความสำคัญต่ำและความสำคัญสูง จะเป็นการปิดการทำงานคุณลักษณะนี้',
+ 'Close a task when there is no activity' => 'ปิดงานเมื่อไม่มีกิจกกรมเกิดขึ้น',
+ 'Duration in days' => 'ระยะเวลาวันที่',
+ 'Send email when there is no activity on a task' => 'ส่งอีเมลเมื่อไม่มีกิจกรรมเกิดขึ้นในงาน',
+ 'List of external links' => 'รายการเชื่อมโยงภายนอก',
+ 'Unable to fetch link information.' => 'ไม่สามารถดึงข้อมูลการเชื่อมโยง',
+ // 'Daily background job for tasks' => '',
+ 'Auto' => 'อัตโนมัติ',
+ 'Related' => 'ที่เกี่ยวข้อง',
+ 'Attachment' => 'แนบ',
+ 'Title not found' => 'ไม่พบหัวเรื่อง',
+ 'Web Link' => 'เวบลิงค์',
+ 'External links' => 'เชื่อมโยงภายนอก',
+ 'Add external link' => 'เพิ่มการเชื่อมโยงภายนอก',
+ 'Type' => 'ประเภท',
+ 'Dependency' => 'ขึ้นอยู่กับ',
+ 'Add internal link' => 'เพิ่มการเชื่อมโยงภายใน',
+ 'Add a new external link' => 'เพิ่มการเชื่อมโยงภายนอกใหม่',
+ 'Edit external link' => 'แก้ไขการเชื่อมโยงภายนอก',
+ 'External link' => 'เชื่อมโยงภายนอก',
+ 'Copy and paste your link here...' => 'คัดลอกและวางลิงค์ของคุณที่นี้...',
+ 'URL' => 'URL',
+ 'There is no external link for the moment.' => 'ขณะนี้ไม่มีการเชื่อมโยงภายนอก',
+ 'Internal links' => 'เชื่อมโยงภายใน',
+ 'There is no internal link for the moment.' => 'ขณะนี้ไม่มีการเชื่อมโยงภายใน',
+ 'Assign to me' => 'ฉันรับผิดชอบ',
+ 'Me' => 'ฉัน',
+ // 'Do not duplicate anything' => '',
+ 'Projects management' => 'การจัดการโปรเจค',
+ 'Users management' => 'การจัดการผู้ใช้',
+ 'Groups management' => 'การจัดการกลุ่ม',
+ 'Create from another project' => 'สร้างโปรเจคอื่น',
+ 'There is no subtask at the moment.' => 'ขณะนี้ไม่มีงานย่อย',
+ 'open' => 'เปิด',
+ 'closed' => 'ปิด',
+ 'Priority:' => 'ความสำคัญ:',
+ 'Reference:' => 'อ้างถึง:',
+ 'Complexity:' => 'ความซับซ้อน:',
+ 'Swimlane:' => 'สวิมเลน:',
+ 'Column:' => 'คอลัมน์:',
+ 'Position:' => 'ตำแหน่ง:',
+ 'Creator:' => 'ผู้สร้าง:',
+ 'Time estimated:' => 'เวลาเฉลี่ย:',
+ '%s hours' => '%s ชั่วโมง',
+ 'Time spent:' => 'ใช้เวลา:',
+ 'Created:' => 'สร้าง:',
+ 'Modified:' => 'แก้ไข:',
+ 'Completed:' => 'เสร็จสิ้น:',
+ 'Started:' => 'เริ่ม:',
+ 'Moved:' => 'ย้าย:',
+ 'Task #%d' => 'งานที่ #%d',
+ 'Sub-tasks' => 'งานย่อย',
+ 'Date and time format' => 'รูปแบบของวันเวลา',
+ 'Time format' => 'รูปแบบของเวลา',
+ 'Start date: ' => 'เริ่มวันที่:',
+ 'End date: ' => 'จบวันที่:',
+ 'New due date: ' => 'วันครบกำหนดใหม่',
+ 'Start date changed: ' => 'เปลี่ยนวันที่เริ่ม',
);
diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php
index 05277797..aa73c1c6 100644
--- a/app/Locale/tr_TR/translations.php
+++ b/app/Locale/tr_TR/translations.php
@@ -20,15 +20,15 @@ return array(
'Red' => 'Kırmızı',
'Orange' => 'Turuncu',
'Grey' => 'Gri',
- // 'Brown' => '',
- // 'Deep Orange' => '',
- // 'Dark Grey' => '',
- // 'Pink' => '',
- // 'Teal' => '',
- // 'Cyan' => '',
- // 'Lime' => '',
- // 'Light Green' => '',
- // 'Amber' => '',
+ 'Brown' => 'Kahverengi',
+ 'Deep Orange' => 'Koyu Turuncu',
+ 'Dark Grey' => 'Koyu Gri',
+ 'Pink' => 'Pembe',
+ 'Teal' => 'Turkuaz',
+ 'Cyan' => 'Cam Göbeği',
+ 'Lime' => 'Limon rengi',
+ 'Light Green' => 'Açık Yeşil',
+ 'Amber' => 'Koyu sarı',
'Save' => 'Kaydet',
'Login' => 'Giriş',
'Official website:' => 'Resmi internet sitesi:',
@@ -72,7 +72,6 @@ return array(
'Remove project' => 'Projeyi sil',
'Edit the board for "%s"' => 'Tabloyu "%s" için güncelle',
'All projects' => 'Tüm projeler',
- 'Change columns' => 'Sütunları değiştir',
'Add a new column' => 'Yeni sütun ekle',
'Title' => 'Başlık',
'Nobody assigned' => 'Kullanıcı atanmamış',
@@ -86,7 +85,7 @@ return array(
'Application settings' => 'Uygulama ayarları',
'Language' => 'Dil',
// 'Webhook token:' => '',
- 'API token:' => 'API Token:',
+ 'API token:' => 'API belirteci:',
'Database size:' => 'Veritabanı boyutu :',
'Download the database' => 'Veritabanını indir',
'Optimize the database' => 'Veritabanını optimize et',
@@ -98,15 +97,12 @@ return array(
'Color' => 'Renk',
'Assignee' => 'Atanan',
'Create another task' => 'Başka bir görev oluştur',
- 'New task' => 'Nouvelle tâche',
+ 'New task' => 'Yeni görev',
'Open a task' => 'Bir görevi aç',
'Do you really want to open this task: "%s"?' => 'Bu görevi gerçekten açmak istiyor musunuz: "%s"?',
'Back to the board' => 'Tabloya dön',
- // 'Created on %B %e, %Y at %k:%M %p' => '',
'There is nobody assigned' => 'Kimse atanmamış',
'Column on the board:' => 'Tablodaki sütun:',
- 'Status is open' => 'Açık durumda',
- 'Status is closed' => 'Kapalı durumda',
'Close this task' => 'Görevi kapat',
'Open this task' => 'Görevi aç',
'There is no description.' => 'Açıklama yok.',
@@ -124,7 +120,6 @@ return array(
'The id is required' => 'Kod gerekli',
'The project id is required' => 'Proje kodu gerekli',
'The project name is required' => 'Proje adı gerekli',
- 'This project must be unique' => 'Bu projenin tekil olması gerekli',
'The title is required' => 'Başlık gerekli',
'Settings saved successfully.' => 'Ayarlar başarıyla kaydedildi.',
'Unable to save your settings.' => 'Ayarlarınız kaydedilemedi.',
@@ -159,29 +154,19 @@ return array(
'Work in progress' => 'İşlemde',
'Done' => 'Tamamlandı',
'Application version:' => 'Uygulama versiyonu:',
- // 'Completed on %B %e, %Y at %k:%M %p' => '',
- // '%B %e, %Y at %k:%M %p' => '',
- 'Date created' => 'Oluşturulma tarihi',
- 'Date completed' => 'Tamamlanma tarihi',
'Id' => 'Kod',
'%d closed tasks' => '%d kapatılmış görevler',
- // 'No task for this project' => '',
+ 'No task for this project' => 'Bu proje için görev yok',
'Public link' => 'Dışa açık link',
'Change assignee' => 'Atanmış Kullanıcıyı değiştir',
'Change assignee for the task "%s"' => '"%s" görevi için atanmış kullanıcıyı değiştir',
'Timezone' => 'Saat dilimi',
- // 'Sorry, I didn\'t find this information in my database!' => '',
+ 'Sorry, I didn\'t find this information in my database!' => 'Üzgünüm, bu bilgiyi veri tabanımda bulamadım.',
'Page not found' => 'Sayfa bulunamadı',
'Complexity' => 'Zorluk seviyesi',
'Task limit' => 'Görev limiti',
'Task count' => 'Görev sayısı',
- 'Edit project access list' => 'Proje erişim listesini düzenle',
- 'Allow this user' => 'Bu kullanıcıya izin ver',
- 'Don\'t forget that administrators have access to everything.' => 'Dikkat: Yöneticilerin herşeye erişimi olduğunu unutmayın!',
- 'Revoke' => 'Iptal et',
- 'List of authorized users' => 'Yetkili kullanıcıların listesi',
'User' => 'Kullanıcı',
- 'Nobody have access to this project.' => 'Bu projeye kimsenin erişimi yok.',
'Comments' => 'Yorumlar',
'Write your text in Markdown' => 'Yazınızı Markdown ile yazın',
'Leave a comment' => 'Bir yorum ekle',
@@ -190,11 +175,8 @@ return array(
'Comment added successfully.' => 'Yorum eklendi',
'Unable to create your comment.' => 'Yorumunuz oluşturulamadı',
'Edit this task' => 'Bu görevi değiştir',
- 'Due Date' => 'Termin',
+ 'Due Date' => 'Bitiş Tarihi',
'Invalid date' => 'Geçersiz tarihi',
- // 'Must be done before %B %e, %Y' => '',
- '%B %e, %Y' => '%d %B %Y',
- '%b %e, %Y' => '%d/%m/%Y',
'Automatic actions' => 'Otomatik işlemler',
'Your automatic action have been created successfully.' => 'Otomatik işlem başarıyla oluşturuldu',
'Unable to create your automatic action.' => 'Otomatik işleminiz oluşturulamadı',
@@ -225,21 +207,18 @@ return array(
'Assign a color to a specific user' => 'Bir kullanıcıya renk tanımla',
'Column title' => 'Sütun başlığı',
'Position' => 'Pozisyon',
- 'Move Up' => 'Yukarı taşı',
- 'Move Down' => 'Aşağı taşı',
'Duplicate to another project' => 'Başka bir projeye kopyala',
'Duplicate' => 'Kopya oluştur',
- 'link' => 'link',
+ 'link' => 'bağlantı',
'Comment updated successfully.' => 'Yorum güncellendi.',
'Unable to update your comment.' => 'Yorum güncellenemedi.',
'Remove a comment' => 'Bir yorumu sil',
'Comment removed successfully.' => 'Yorum silindi.',
'Unable to remove this comment.' => 'Bu yorum silinemiyor.',
'Do you really want to remove this comment?' => 'Bu yorumu silmek istediğinize emin misiniz?',
- 'Only administrators or the creator of the comment can access to this page.' => 'Bu sayfaya yalnızca yorum sahibi ve yöneticiler erişebilir.',
'Current password for the user "%s"' => 'Kullanıcı için mevcut şifre "%s"',
'The current password is required' => 'Mevcut şifre gerekli',
- 'Wrong password' => 'Yanlış Şifre',
+ 'Wrong password' => 'Yanlış şifre',
'Unknown' => 'Bilinmeyen',
'Last logins' => 'Son kullanıcı girişleri',
'Login date' => 'Giriş tarihi',
@@ -247,7 +226,7 @@ return array(
'IP address' => 'IP adresi',
'User agent' => 'Kullanıcı sistemi',
'Persistent connections' => 'Kalıcı bağlantılar',
- // 'No session.' => '',
+ 'No session.' => 'Oturum yok.',
'Expiration date' => 'Geçerlilik sonu',
'Remember Me' => 'Beni hatırla',
'Creation date' => 'Oluşturulma tarihi',
@@ -255,41 +234,37 @@ return array(
'Open' => 'Açık',
'Closed' => 'Kapalı',
'Search' => 'Ara',
- 'Nothing found.' => 'Hiçbir şey bulunamadı',
- 'Due date' => 'Termin',
+ 'Nothing found.' => 'Hiçbir şey bulunamadı.',
+ 'Due date' => 'Bitiş tarihi',
'Others formats accepted: %s and %s' => 'Diğer kabul edilen formatlar: %s ve %s',
'Description' => 'Açıklama',
- '%d comments' => '%d yorumlar',
+ '%d comments' => '%d yorum',
'%d comment' => '%d yorum',
- 'Email address invalid' => 'E-Posta adresi geçersiz',
- // 'Your external account is not linked anymore to your profile.' => '',
- // 'Unable to unlink your external account.' => '',
- // 'External authentication failed' => '',
- // 'Your external account is linked to your profile successfully.' => '',
- 'Email' => 'E-Posta',
- 'Link my Google Account' => 'Google hesabımla bağ oluştur',
- 'Unlink my Google Account' => 'Google hesabımla bağı kaldır',
- 'Login with my Google Account' => 'Google hesabımla giriş yap',
- 'Project not found.' => 'Proje bulunamadı',
- 'Task removed successfully.' => 'Görev silindi',
- 'Unable to remove this task.' => 'Görev silinemiyor',
+ 'Email address invalid' => 'E-posta adresi geçersiz',
+ 'Your external account is not linked anymore to your profile.' => 'Harici hesabınız artık profilinize bağlı değil',
+ 'Unable to unlink your external account.' => 'Harici hesabınızla bağlantı koparılamadı',
+ 'External authentication failed' => 'Harici hesap doğrulaması başarısız',
+ 'Your external account is linked to your profile successfully.' => 'Harici hesabınız profilinizle başarıyla bağlandı.',
+ 'Email' => 'E-posta',
+ 'Task removed successfully.' => 'Görev başarıyla silindi.',
+ 'Unable to remove this task.' => 'Görev silinemiyor.',
'Remove a task' => 'Bir görevi sil',
'Do you really want to remove this task: "%s"?' => 'Bu görevi silmek istediğinize emin misiniz: "%s"?',
'Assign automatically a color based on a category' => 'Kategoriye göre otomatik renk ata',
'Assign automatically a category based on a color' => 'Rengine göre otomatik kategori ata',
- 'Task creation or modification' => 'Görev oluşturma veya değiştirme',
+ 'Task creation or modification' => 'Görev oluşturma veya düzenleme',
'Category' => 'Kategori',
'Category:' => 'Kategori:',
'Categories' => 'Kategoriler',
- 'Category not found.' => 'Kategori bulunamadı',
- 'Your category have been created successfully.' => 'Kategori oluşturuldu',
- 'Unable to create your category.' => 'Kategori oluşturulamadı',
- 'Your category have been updated successfully.' => 'Kategori başarıyla güncellendi',
- 'Unable to update your category.' => 'Kategori güncellenemedi',
+ 'Category not found.' => 'Kategori bulunamadı.',
+ 'Your category have been created successfully.' => 'Kategori başarıyla oluşturuldu.',
+ 'Unable to create your category.' => 'Kategori oluşturulamadı.',
+ 'Your category have been updated successfully.' => 'Kategori başarıyla güncellendi.',
+ 'Unable to update your category.' => 'Kategori güncellenemedi.',
'Remove a category' => 'Bir kategoriyi sil',
- 'Category removed successfully.' => 'Kategori silindi',
- 'Unable to remove this category.' => 'Bu kategori silinemedi',
- 'Category modification for the project "%s"' => '"%s" projesi için kategori değiştirme',
+ 'Category removed successfully.' => 'Kategori başarıyla silindi.',
+ 'Unable to remove this category.' => 'Bu kategori silinemedi.',
+ 'Category modification for the project "%s"' => '"%s" projesi için kategori düzenleme',
'Category Name' => 'Kategori adı',
'Add a new category' => 'Yeni kategori ekle',
'Do you really want to remove this category: "%s"?' => 'Bu kategoriyi silmek istediğinize emin misiniz: "%s"?',
@@ -317,41 +292,37 @@ return array(
'estimated' => 'tahmini',
'Sub-Tasks' => 'Alt Görev',
'Add a sub-task' => 'Alt görev ekle',
- // 'Original estimate' => '',
+ 'Original estimate' => 'Orjinal tahmin',
'Create another sub-task' => 'Başka bir alt görev daha oluştur',
- // 'Time spent' => '',
+ 'Time spent' => 'Harcanan zaman',
'Edit a sub-task' => 'Alt görev düzenle',
'Remove a sub-task' => 'Alt görev sil',
- 'The time must be a numeric value' => 'Zaman alfanumerik bir değer olmalı',
+ 'The time must be a numeric value' => 'Zaman alfanümerik bir değer olmalı',
'Todo' => 'Yapılacaklar',
- 'In progress' => 'İşlemde',
- 'Sub-task removed successfully.' => 'Alt görev silindi',
- 'Unable to remove this sub-task.' => 'Alt görev silinemedi',
- 'Sub-task updated successfully.' => 'Alt görev güncellendi',
- 'Unable to update your sub-task.' => 'Alt görev güncellenemiyor',
- 'Unable to create your sub-task.' => 'Alt görev oluşturulamadı',
- 'Sub-task added successfully.' => 'Alt görev başarıyla eklendii',
+ 'In progress' => 'Devam etmekte',
+ 'Sub-task removed successfully.' => 'Alt görev başarıyla silindi.',
+ 'Unable to remove this sub-task.' => 'Alt görev silinemedi.',
+ 'Sub-task updated successfully.' => 'Alt görev güncellendi.',
+ 'Unable to update your sub-task.' => 'Alt görev güncellenemiyor.',
+ 'Unable to create your sub-task.' => 'Alt görev oluşturulamadı.',
+ 'Sub-task added successfully.' => 'Alt görev başarıyla eklendi.',
'Maximum size: ' => 'Maksimum boyutu',
- 'Unable to upload the file.' => 'Karşıya yükleme başarısız',
+ 'Unable to upload the file.' => 'Dosya yüklenemiyor.',
'Display another project' => 'Başka bir proje göster',
- // 'Login with my Github Account' => '',
- // 'Link my Github Account' => '',
- // 'Unlink my Github Account' => '',
'Created by %s' => '%s tarafından oluşturuldu',
- 'Last modified on %B %e, %Y at %k:%M %p' => 'Son değişiklik tarihi %d.%m.%Y, saati %H:%M',
'Tasks Export' => 'Görevleri dışa aktar',
'Tasks exportation for "%s"' => '"%s" için görevleri dışa aktar',
'Start Date' => 'Başlangıç tarihi',
'End Date' => 'Bitiş tarihi',
'Execute' => 'Gerçekleştir',
- 'Task Id' => 'Görev No',
+ 'Task Id' => 'Görev Kimliği',
'Creator' => 'Oluşturan',
'Modification date' => 'Değişiklik tarihi',
'Completion date' => 'Tamamlanma tarihi',
'Clone' => 'Kopya oluştur',
'Project cloned successfully.' => 'Proje kopyası başarıyla oluşturuldu.',
- 'Unable to clone this project.' => 'Proje kopyası oluşturulamadı.',
- 'Enable email notifications' => 'E-Posta bilgilendirmesini aç',
+ 'Unable to clone this project.' => 'Proje kopyası oluşturulamıyor.',
+ 'Enable email notifications' => 'E-posta bilgilendirmesini aç',
'Task position:' => 'Görev pozisyonu',
'The task #%d have been opened.' => '#%d numaralı görev açıldı.',
'The task #%d have been closed.' => '#%d numaralı görev kapatıldı.',
@@ -372,9 +343,8 @@ return array(
'Task closed' => 'Görev kapatıldı',
'Task opened' => 'Görev açıldı',
'I want to receive notifications only for those projects:' => 'Yalnızca bu projelerle ilgili bildirim almak istiyorum:',
- 'view the task on Kanboard' => 'bu görevi Kanboard\'da göster',
+ 'view the task on Kanboard' => 'bu görevi Kanboard üzerinde göster',
'Public access' => 'Dışa açık erişim',
- 'User management' => 'Kullanıcı yönetimi',
'Active tasks' => 'Aktif görevler',
'Disable public access' => 'Dışa açık erişimi kapat',
'Enable public access' => 'Dışa açık erişimi aç',
@@ -394,24 +364,18 @@ return array(
'Disabled' => 'Devre dışı bırakıldı',
'Username:' => 'Kullanıcı adı',
'Name:' => 'Ad',
- 'Email:' => 'E-Posta',
+ 'Email:' => 'E-posta',
'Notifications:' => 'Bildirimler:',
'Notifications' => 'Bildirimler',
- 'Group:' => 'Grup',
- 'Regular user' => 'Varsayılan kullanıcı',
'Account type:' => 'Hesap türü:',
'Edit profile' => 'Profili değiştir',
'Change password' => 'Şifre değiştir',
'Password modification' => 'Şifre değişimi',
'External authentications' => 'Dış kimlik doğrulamaları',
- 'Google Account' => 'Google hesabı',
- 'Github Account' => 'Github hesabı',
'Never connected.' => 'Hiç bağlanmamış.',
- 'No account linked.' => 'Bağlanmış hesap yok.',
- 'Account linked.' => 'Hesap bağlandı',
'No external authentication enabled.' => 'Dış kimlik doğrulama kapalı.',
'Password modified successfully.' => 'Şifre başarıyla değiştirildi.',
- 'Unable to change the password.' => 'Şifre değiştirilemedi.',
+ 'Unable to change the password.' => 'Şifre değiştirilemiyor.',
'Change category for the task "%s"' => '"%s" görevi için kategori değiştirme',
'Change category' => 'Kategori değiştirme',
'%s updated the task %s' => '%s kullanıcısı %s görevini güncelledi',
@@ -446,27 +410,20 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s kullanıcısı %s görevinin sorumlusunu %s olarak değiştirdi',
'New password for the user "%s"' => '"%s" kullanıcısı için yeni şifre',
'Choose an event' => 'Bir durum seçin',
- // 'Github commit received' => '',
- // 'Github issue opened' => '',
- // 'Github issue closed' => '',
- // 'Github issue reopened' => '',
- // 'Github issue assignee change' => '',
- // 'Github issue label change' => '',
'Create a task from an external provider' => 'Dış sağlayıcı ile bir görev oluştur',
'Change the assignee based on an external username' => 'Dış kaynaklı kullanıcı adı ile göreve atananı değiştir',
'Change the category based on an external label' => 'Dış kaynaklı bir etiket ile kategori değiştir',
'Reference' => 'Referans',
- 'Reference: %s' => 'Referans: %s',
'Label' => 'Etiket',
- 'Database' => 'Veri bankası',
+ 'Database' => 'Veritabanı',
'About' => 'Hakkında',
- 'Database driver:' => 'Veri bankası sürücüsü',
+ 'Database driver:' => 'Veritabanı sürücüsü:',
'Board settings' => 'Tablo ayarları',
- 'URL and token' => 'URL veya Token',
+ 'URL and token' => 'URL veya Belirteç',
'Webhook settings' => 'Webhook ayarları',
'URL for task creation:' => 'Görev oluşturma için URL',
- 'Reset token' => 'Reset Token',
- 'API endpoint:' => 'API endpoint',
+ 'Reset token' => 'Belirteci sıfırla',
+ 'API endpoint:' => 'API bitiş noktası:',
'Refresh interval for private board' => 'Özel tablolar için yenileme sıklığı',
'Refresh interval for public board' => 'Dışa açık tablolar için yenileme sıklığı',
'Task highlight period' => 'Görevi öne çıkarma süresi',
@@ -474,17 +431,13 @@ return array(
'Frequency in second (60 seconds by default)' => 'Saniye olarak frekans (varsayılan 60 saniye)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Saniye olarak frekans (Bu özelliği iptal etmek için 0, varsayılan değer 10 saniye)',
'Application URL' => 'Uygulama URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Örneğin: http://example.kanboard.net/ (E-posta bildirimleri için kullanılıyor)',
- 'Token regenerated.' => 'Token yeniden oluşturuldu.',
+ 'Token regenerated.' => 'Beliteç yeniden oluşturuldu.',
'Date format' => 'Tarih formatı',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO formatı her zaman kabul edilir, örneğin: "%s" ve "%s"',
'New private project' => 'Yeni özel proje',
'This project is private' => 'Bu proje özel',
'Type here to create a new sub-task' => 'Yeni bir alt görev oluşturmak için buraya yazın',
'Add' => 'Ekle',
- 'Estimated time: %s hours' => 'Tahmini süre: %s Saat',
- 'Time spent: %s hours' => 'Kullanılan süre: %s Saat',
- 'Started on %B %e, %Y' => '%B %e %Y tarihinde başlatıldı',
'Start date' => 'Başlangıç tarihi',
'Time estimated' => 'Tahmini süre',
'There is nothing assigned to you.' => 'Size atanan hiçbir şey yok.',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => 'Bu projeye herkesin erişimi var.',
'Webhooks' => 'Webhooks',
'API' => 'API',
- 'Github webhooks' => 'Github Webhook',
- 'Help on Github webhooks' => 'Github Webhooks hakkında yardım',
'Create a comment from an external provider' => 'Dış sağlayıcı ile bir yorum oluştur',
- 'Github issue comment created' => 'Github hata yorumu oluşturuldu',
'Project management' => 'Proje yönetimi',
'My projects' => 'Projelerim',
'Columns' => 'Sütunlar',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => '"%s" için kullanıcı dağılımı',
'Clone this project' => 'Projenin kopyasını oluştur',
'Column removed successfully.' => 'Sütun başarıyla kaldırıldı.',
- 'Github Issue' => 'Github Issue',
'Not enough data to show the graph.' => 'Grafik gösterimi için yeterli veri yok.',
'Previous' => 'Önceki',
'The id must be an integer' => 'ID bir tamsayı olmalı',
@@ -547,10 +496,7 @@ return array(
'Default swimlane' => 'Varsayılan Kulvar',
'Do you really want to remove this swimlane: "%s"?' => 'Bu Kulvarı silmek istediğinize emin misiniz?: "%s"?',
'Inactive swimlanes' => 'Pasif Kulvarlar',
- 'Set project manager' => 'Proje yöneticisi olarak ata',
- 'Set project member' => 'Proje üyesi olarak ata',
'Remove a swimlane' => 'Kulvarı sil',
- 'Rename' => 'Yeniden adlandır',
'Show default swimlane' => 'Varsayılan Kulvarı göster',
'Swimlane modification for the project "%s"' => '"%s" Projesi için Kulvar değişikliği',
'Swimlane not found.' => 'Kulvar bulunamadı',
@@ -558,24 +504,13 @@ return array(
'Swimlanes' => 'Kulvarlar',
'Swimlane updated successfully.' => 'Kulvar başarıyla güncellendi.',
'The default swimlane have been updated successfully.' => 'Varsayılan Kulvarlar başarıyla güncellendi.',
- 'Unable to create your swimlane.' => 'Bu Kulvarı oluşturmak mümkün değil.',
'Unable to remove this swimlane.' => 'Bu Kulvarı silmek mümkün değil.',
'Unable to update this swimlane.' => 'Bu Kulvarı değiştirmek mümkün değil.',
'Your swimlane have been created successfully.' => 'Kulvar başarıyla oluşturuldu.',
'Example: "Bug, Feature Request, Improvement"' => 'Örnek: "Sorun, Özellik talebi, İyileştirme"',
'Default categories for new projects (Comma-separated)' => 'Yeni projeler için varsayılan kategoriler (Virgül ile ayrılmış)',
- // 'Gitlab commit received' => '',
- // 'Gitlab issue opened' => '',
- // 'Gitlab issue closed' => '',
- // 'Gitlab webhooks' => '',
- // 'Help on Gitlab webhooks' => '',
'Integrations' => 'Entegrasyon',
'Integration with third-party services' => 'Dış servislerle entegrasyon',
- 'Role for this project' => 'Bu proje için rol',
- 'Project manager' => 'Proje Yöneticisi',
- 'Project member' => 'Proje Üyesi',
- 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Bir Proje Yöneticisi proje ayarlarını değiştirebilir ve bir üyeden daha fazla yetkiye sahiptir.',
- // 'Gitlab Issue' => '',
'Subtask Id' => 'Alt görev No:',
'Subtasks' => 'Alt görevler',
'Subtasks Export' => 'Alt görevleri dışa aktar',
@@ -602,10 +537,7 @@ return array(
'Time Tracking' => 'Zaman takibi',
'You already have one subtask in progress' => 'Zaten işlemde olan bir alt görev var',
'Which parts of the project do you want to duplicate?' => 'Projenin hangi kısımlarının kopyasını oluşturmak istiyorsunuz?',
- // 'Disallow login form' => '',
- 'Bitbucket commit received' => 'Bitbucket commit alındı',
- 'Bitbucket webhooks' => 'Bitbucket webhooks',
- 'Help on Bitbucket webhooks' => 'Bitbucket webhooks için yardım',
+ 'Disallow login form' => 'Giriş formu erişimini engelle',
'Start' => 'Başlangıç',
'End' => 'Son',
'Task age in days' => 'Görev yaşı gün olarak',
@@ -646,7 +578,6 @@ return array(
'This task' => 'Bu görev',
'<1h' => '<1s',
'%dh' => '%ds',
- // '%b %e' => '',
'Expand tasks' => 'Görevleri genişlet',
'Collapse tasks' => 'Görevleri daralt',
'Expand/collapse tasks' => 'Görevleri genişlet/daralt',
@@ -656,20 +587,17 @@ return array(
'Keyboard shortcuts' => 'Klavye kısayolları',
'Open board switcher' => 'Tablo seçim listesini aç',
'Application' => 'Uygulama',
- 'since %B %e, %Y at %k:%M %p' => '%B %e, %Y saat %k:%M %p\'den beri',
'Compact view' => 'Ekrana sığdır',
'Horizontal scrolling' => 'Geniş görünüm',
'Compact/wide view' => 'Ekrana sığdır / Geniş görünüm',
- // 'No results match:' => '',
- // 'Currency' => '',
- // 'Files' => '',
- // 'Images' => '',
- // 'Private project' => '',
+ 'No results match:' => 'Uygun sonuç bulunamadı',
+ 'Currency' => 'Para birimi',
+ 'Private project' => 'Özel proje',
// 'AUD - Australian Dollar' => '',
// 'CAD - Canadian Dollar' => '',
// 'CHF - Swiss Francs' => '',
// 'Custom Stylesheet' => '',
- // 'download' => '',
+ 'download' => 'indir',
// 'EUR - Euro' => '',
// 'GBP - British Pound' => '',
// 'INR - Indian Rupee' => '',
@@ -677,391 +605,547 @@ return array(
// 'NZD - New Zealand Dollar' => '',
// 'RSD - Serbian dinar' => '',
// 'USD - US Dollar' => '',
- // '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' => '',
- // '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' => '',
- // 'Reference currency' => '',
- // 'The currency rate have been added successfully.' => '',
- // 'Unable to add this currency rate.' => '',
+ 'Destination column' => 'Hedef Sütun',
+ 'Move the task to another column when assigned to a user' => 'Bir kullanıcıya atandığında görevi başka bir sütuna taşı',
+ 'Move the task to another column when assignee is cleared' => 'Atanmış kullanıcı kaldırıldığında görevi başka bir sütuna taşı',
+ 'Source column' => 'Kaynak sütun',
+ 'Transitions' => 'Geçişler',
+ 'Executer' => 'Uygulayıcı',
+ 'Time spent in the column' => 'Sütunda harcanan süre',
+ 'Task transitions' => 'Görev geçişleri',
+ 'Task transitions export' => 'Görev geçişlerini dışa aktar',
+ 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Bu rapor her bir görevin sütunlar arası geçişlerini tarih, kullanıcı ve sütunda harcanan zaman detaylarıyla içerir.',
+ 'Currency rates' => 'Döviz kurları',
+ 'Rate' => 'Kurlar',
+ 'Change reference currency' => 'Referans kur değiştir',
+ 'Add a new currency rate' => 'Yeni bir kur ekle',
+ 'Reference currency' => 'Referans kur',
+ 'The currency rate have been added successfully.' => 'Kur başarıyla eklendi',
+ 'Unable to add this currency rate.' => 'Bu kur eklenemedi',
// 'Webhook URL' => '',
- // '%s remove the assignee of the task %s' => '',
- // 'Enable Gravatar images' => '',
- // 'Information' => '',
- // 'Check two factor authentication code' => '',
- // 'The two factor authentication code is not valid.' => '',
- // 'The two factor authentication code is valid.' => '',
- // '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' => '',
- // '%s via Kanboard' => '',
- // 'uploaded by: %s' => '',
- // 'uploaded on: %s' => '',
- // 'size: %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.' => '',
+ '%s remove the assignee of the task %s' => '%s, %s görevinin atanan bilgisini kaldırdı',
+ 'Enable Gravatar images' => 'Gravatar resimlerini kullanıma aç',
+ 'Information' => 'Bilgi',
+ 'Check two factor authentication code' => 'İki kademeli doğrulama kodunu kontrol et',
+ 'The two factor authentication code is not valid.' => 'İki kademeli doğrulama kodu geçersiz',
+ 'The two factor authentication code is valid.' => 'İki kademeli doğrulama kodu onaylandı',
+ 'Code' => 'Kod',
+ 'Two factor authentication' => 'İki kademeli doğrulama',
+ 'This QR code contains the key URI: ' => 'Bu QR kodu anahtar URI içerir',
+ 'Check my code' => 'Kodu kontrol et',
+ 'Secret key: ' => 'Gizli anahtar',
+ 'Test your device' => 'Cihazınızı test edin',
+ 'Assign a color when the task is moved to a specific column' => 'Görev belirli bir sütuna taşındığında rengini değiştir',
+ '%s via Kanboard' => '%s Kanboard ile',
+ 'Burndown chart for "%s"' => '%s icin kalan iş grafiği',
+ 'Burndown chart' => 'Kalan iş grafiği',
+ 'This chart show the task complexity over the time (Work Remaining).' => 'Bu grafik zorluk seviyesini zamana göre gösterir (kalan iş)',
+ 'Screenshot taken %s' => 'Ekran görüntüsü alındı %s',
+ 'Add a screenshot' => 'Bir ekran görüntüsü ekle',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Bir ekran görüntüsü alın ve buraya yapıştırmak için CTRL+V veya ⌘+V tuşlarına basın.',
+ 'Screenshot uploaded successfully.' => 'Ekran görüntüsü başarıyla yüklendi',
// 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
- // 'Identifier' => '',
- // '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' => '',
- // '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)' => '',
- // '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.' => '',
- // 'User that will receive the email' => '',
- // 'Email subject' => '',
- // 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
- // 'Add a comment log when moving the task between columns' => '',
- // 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
- // 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
- // 'Column change' => '',
- // 'Position change' => '',
- // 'Swimlane change' => '',
- // 'Assignee change' => '',
- // '[%s] Overdue tasks' => '',
- // 'Notification' => '',
- // '%s moved the task #%d to the first swimlane' => '',
- // '%s moved the task #%d to the swimlane "%s"' => '',
- // 'Swimlane' => '',
- // 'Gravatar' => '',
- // '%s moved the task %s to the first swimlane' => '',
- // '%s moved the task %s to the swimlane "%s"' => '',
- // 'This report contains all subtasks information for the given date range.' => '',
- // 'This report contains all tasks information for the given date range.' => '',
- // 'Project activities for %s' => '',
- // 'view the board on Kanboard' => '',
- // 'The task have been moved to the first swimlane' => '',
- // 'The task have been moved to another swimlane:' => '',
- // 'Overdue tasks for the project "%s"' => '',
- // 'New title: %s' => '',
- // 'The task is not assigned anymore' => '',
- // 'New assignee: %s' => '',
- // 'There is no category now' => '',
- // 'New category: %s' => '',
- // 'New color: %s' => '',
- // 'New complexity: %d' => '',
- // 'The due date have been removed' => '',
- // 'There is no description anymore' => '',
- // 'Recurrence settings have been modified' => '',
- // 'Time spent changed: %sh' => '',
- // 'Time estimated changed: %sh' => '',
- // 'The field "%s" have been updated' => '',
- // 'The description have been modified' => '',
- // 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
- // 'I want to receive notifications for:' => '',
- // 'All tasks' => '',
- // 'Only for tasks assigned to me' => '',
- // 'Only for tasks created by me' => '',
- // 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
- // '%%Y-%%m-%%d' => '',
- // 'Total for all columns' => '',
- // 'You need at least 2 days of data to show the chart.' => '',
- // '<15m' => '',
- // '<30m' => '',
- // 'Stop timer' => '',
- // 'Start timer' => '',
- // 'Add project member' => '',
- // 'Enable notifications' => '',
- // 'My activity stream' => '',
- // 'My calendar' => '',
- // 'Search tasks' => '',
- // 'Back to the calendar' => '',
- // 'Filters' => '',
- // 'Reset filters' => '',
- // 'My tasks due tomorrow' => '',
- // 'Tasks due today' => '',
- // 'Tasks due tomorrow' => '',
- // 'Tasks due yesterday' => '',
- // 'Closed tasks' => '',
- // 'Open tasks' => '',
- // 'Not assigned' => '',
- // 'View advanced search syntax' => '',
- // 'Overview' => '',
- // '%b %e %Y' => '',
- // 'Board/Calendar/List view' => '',
- // 'Switch to the board view' => '',
- // 'Switch to the calendar view' => '',
- // 'Switch to the list view' => '',
- // 'Go to the search/filter box' => '',
- // 'There is no activity yet.' => '',
- // 'No tasks found.' => '',
- // 'Keyboard shortcut: "%s"' => '',
- // 'List' => '',
- // 'Filter' => '',
- // 'Advanced search' => '',
- // 'Example of query: ' => '',
- // 'Search by project: ' => '',
- // 'Search by column: ' => '',
- // 'Search by assignee: ' => '',
- // 'Search by color: ' => '',
- // 'Search by category: ' => '',
- // 'Search by description: ' => '',
- // 'Search by due date: ' => '',
- // 'Lead and Cycle time for "%s"' => '',
- // 'Average time spent into each column for "%s"' => '',
- // 'Average time spent into each column' => '',
- // 'Average time spent' => '',
- // 'This chart show the average time spent into each column for the last %d tasks.' => '',
- // 'Average Lead and Cycle time' => '',
- // 'Average lead time: ' => '',
- // 'Average cycle time: ' => '',
- // 'Cycle Time' => '',
- // 'Lead Time' => '',
- // 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
- // 'Average time into each column' => '',
- // 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
- // 'Lead time: ' => '',
- // 'Cycle time: ' => '',
- // 'Time spent into each column' => '',
- // 'The lead time is the duration between the task creation and the completion.' => '',
- // 'The cycle time is the duration between the start date and the completion.' => '',
- // 'If the task is not closed the current time is used instead of the completion date.' => '',
- // 'Set automatically the start date' => '',
- // 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
- // 'Remote user' => '',
- // 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
- // 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
- // 'New remote user' => '',
- // 'New local user' => '',
- // 'Default task color' => '',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
- // 'This feature does not work with all browsers.' => '',
- // 'There is no destination project available.' => '',
- // 'Trigger automatically subtask time tracking' => '',
- // 'Include closed tasks in the cumulative flow diagram' => '',
- // 'Current swimlane: %s' => '',
- // 'Current column: %s' => '',
- // 'Current category: %s' => '',
- // 'no category' => '',
- // 'Current assignee: %s' => '',
- // 'not assigned' => '',
- // 'Author:' => '',
- // 'contributors' => '',
- // 'License:' => '',
- // 'License' => '',
- // 'Project Administrator' => '',
- // 'Enter the text below' => '',
- // 'Gantt chart for %s' => '',
- // 'Sort by position' => '',
- // 'Sort by date' => '',
- // 'Add task' => '',
- // 'Start date:' => '',
- // 'Due date:' => '',
- // 'There is no start date or due date for this task.' => '',
- // 'Moving or resizing a task will change the start and due date of the task.' => '',
- // 'There is no task in your project.' => '',
- // 'Gantt chart' => '',
- // 'People who are project managers' => '',
- // 'People who are project members' => '',
+ 'Identifier' => 'Kimlik',
+ 'Disable two factor authentication' => 'İki kademeli doğrulamayı iptal et',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Bu kullanıcı için iki kademeli doğrulamayı iptal etmek istediğinize emin misiniz: "%s"?',
+ 'Edit link' => 'Linki düzenle',
+ 'Start to type task title...' => 'Görev başlığını yazmaya başlayın...',
+ 'A task cannot be linked to itself' => 'Bir görevden kendine link atanamaz',
+ 'The exact same link already exists' => 'Birebir aynı link zaten var',
+ 'Recurrent task is scheduled to be generated' => 'Tekrarlanan görev oluşturulması zamanlandı',
+ 'Score' => 'Skor',
+ 'The identifier must be unique' => 'Kimlik özgün olmalı',
+ 'This linked task id doesn\'t exists' => 'Link oluşturulan görec kodu mevcut değil',
+ 'This value must be alphanumeric' => 'Bu değer alfanumerik olmalı',
+ 'Edit recurrence' => 'Tekrarlanmayı düzenle',
+ 'Generate recurrent task' => 'Tekrarlanan görev oluştur',
+ 'Trigger to generate recurrent task' => 'Tekrarlanan görev oluşturmak için tetikleyici',
+ 'Factor to calculate new due date' => 'Yeni tamamlanma tarihi için hesaplama faktörü',
+ 'Timeframe to calculate new due date' => 'Yeni tamamlanma tarihinin hesaplanması için zaman dilimi',
+ 'Base date to calculate new due date' => 'Yeni tamamlanma tarihinin hesaplanması için baz alınacak tarih',
+ 'Action date' => 'Hareket tarihi',
+ 'Base date to calculate new due date: ' => 'Yeni tamamlanma tarihinin hesaplanması için baz alınacak tarih',
+ 'This task has created this child task: ' => 'Bu görev şu alt görevi oluşturdu:',
+ 'Day(s)' => 'Gün(ler)',
+ 'Existing due date' => 'Mevcut tamamlanma tarihi',
+ 'Factor to calculate new due date: ' => 'Yeni tamamlanma tarihi için hesaplama faktörü',
+ 'Month(s)' => 'Ay(lar)',
+ 'Recurrence' => 'Tekrar',
+ 'This task has been created by: ' => 'Bu görev şunun tarafından oluşturuldu:',
+ 'Recurrent task has been generated:' => 'Tekrarlanan görev oluşturuldu:',
+ 'Timeframe to calculate new due date: ' => 'Yeni tamamlanma tarihinin hesaplanması için zaman dilimi',
+ 'Trigger to generate recurrent task: ' => 'Tekrarlanan görev oluşturmak için tetikleyici',
+ 'When task is closed' => 'Görev kapatıldığı zaman',
+ 'When task is moved from first column' => 'Görev ilk sütundan taşındığı zaman',
+ 'When task is moved to last column' => 'Görev son sütuna taşındığı zaman',
+ 'Year(s)' => 'Yıl(lar)',
+ 'Calendar settings' => 'Takvim ayarları',
+ 'Project calendar view' => 'Proje takvim görünümü',
+ 'Project settings' => 'Proje ayarları',
+ 'Show subtasks based on the time tracking' => 'Zaman takibi bazında alt görevleri göster',
+ 'Show tasks based on the creation date' => 'Oluşturulma zamanına göre görevleri göster',
+ 'Show tasks based on the start date' => 'Başlangıç zamanına göre görevleri göster',
+ 'Subtasks time tracking' => 'Alt görevler zaman takibi',
+ 'User calendar view' => 'Kullanıcı takvim görünümü',
+ 'Automatically update the start date' => 'Başlangıç tarihini otomatik olarak güncelle',
+ 'iCal feed' => 'iCal akışı',
+ 'Preferences' => 'Ayarlar',
+ 'Security' => 'Güvenlik',
+ 'Two factor authentication disabled' => 'İki kademeli doğrulamayı devre dışı bırak',
+ 'Two factor authentication enabled' => 'İki kademeli doğrulamayı etkinleştir',
+ 'Unable to update this user.' => 'Bu kullanıcı güncellenemiyor',
+ 'There is no user management for private projects.' => 'Özel projeler için kullanıcı yönetimi yoktur.',
+ 'User that will receive the email' => 'Email alacak kullanıcı',
+ 'Email subject' => 'Email başlığı',
+ 'Date' => 'Tarih',
+ 'Add a comment log when moving the task between columns' => 'Görevi sütunlar arasında taşırken yorum kaydı ekle',
+ 'Move the task to another column when the category is changed' => 'Kategori değiştirildiğinde görevi başka sütuna taşı',
+ 'Send a task by email to someone' => 'Bir görevi email ile birine gönder',
+ 'Reopen a task' => 'Bir görevi tekrar aç',
+ 'Column change' => 'Sütun değişikliği',
+ 'Position change' => 'Konum değişikliği',
+ 'Swimlane change' => 'Kulvar değişikliği',
+ 'Assignee change' => 'Atanan değişikliği',
+ '[%s] Overdue tasks' => '[%s] Gecikmiş görevler',
+ 'Notification' => 'Uyarılar',
+ '%s moved the task #%d to the first swimlane' => '%s, #%d görevini birinci kulvara taşıdı',
+ '%s moved the task #%d to the swimlane "%s"' => '%s, #%d görevini "%s" kulvarına taşıdı',
+ 'Swimlane' => 'Kulvar',
+ 'Gravatar' => 'Gravatar',
+ '%s moved the task %s to the first swimlane' => '%s, %s görevini ilk kulvara taşıdı',
+ '%s moved the task %s to the swimlane "%s"' => '%s, %s görevini "%s" kulvarına taşıdı',
+ 'This report contains all subtasks information for the given date range.' => 'Bu rapor belirtilen tarih aralığında tüm alt görev bilgilerini içerir.',
+ 'This report contains all tasks information for the given date range.' => 'Bu rapor belirtilen tarih aralığında tüm görev bilgilerini içerir.',
+ 'Project activities for %s' => '%s için proje aktiviteleri',
+ 'view the board on Kanboard' => 'Tabloyu Kanboard\'da görüntüle',
+ 'The task have been moved to the first swimlane' => 'Görev birinci kulvara taşındı',
+ 'The task have been moved to another swimlane:' => 'Görev başka bir kulvara taşındı:',
+ 'Overdue tasks for the project "%s"' => '"%s" projesi için gecikmiş görevler',
+ 'New title: %s' => 'Yeni başlık: %s',
+ 'The task is not assigned anymore' => 'Görev artık atanmamış',
+ 'New assignee: %s' => 'Yeni atanan: %s',
+ 'There is no category now' => 'Şu anda kategori yok',
+ 'New category: %s' => 'Yeni kategori: %s',
+ 'New color: %s' => 'Yeni renk: %s',
+ 'New complexity: %d' => 'Yeni zorluk seviyesi: %d',
+ 'The due date have been removed' => 'Tamamlanma tarihi silindi',
+ 'There is no description anymore' => 'Artık açıklama yok',
+ 'Recurrence settings have been modified' => 'Tekrarlanma ayarları değiştirildi',
+ 'Time spent changed: %sh' => 'Harcanan zaman değiştirildi: %sh',
+ 'Time estimated changed: %sh' => 'Tahmini süre değiştirildi: %sh',
+ 'The field "%s" have been updated' => '"%s" hanesi değiştirildi',
+ 'The description have been modified' => 'Açıklama değiştirildi',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => '"%s" görevini ve tüm alt görevlerini kapatmak istediğinize emin misiniz?',
+ 'I want to receive notifications for:' => 'Bununla ilgili bildirimler almak istiyorum:',
+ 'All tasks' => 'Tüm görevler',
+ 'Only for tasks assigned to me' => 'Yalnızca bana atanmış görevler için',
+ 'Only for tasks created by me' => 'Yalnızca benim oluşturduğum görevler için',
+ 'Only for tasks created by me and assigned to me' => 'Yalnızca benim oluşturduğum ve bana atanmış görevler için',
+ '%%Y-%%m-%%d' => '%%Y-%%m-%%d',
+ 'Total for all columns' => 'Tüm sütunlar için toplam',
+ 'You need at least 2 days of data to show the chart.' => 'Grafiği göstermek için en az iki günlük veriye ihtiyaç var.',
+ '<15m' => '<15dk',
+ '<30m' => '<30dk',
+ 'Stop timer' => 'Zamanlayıcıyı durdur',
+ 'Start timer' => 'Zamanlayıcıyı başlat',
+ 'Add project member' => 'Proje üyesi ekle',
+ 'Enable notifications' => 'Bildirimleri etkinleştir',
+ 'My activity stream' => 'Olay akışım',
+ 'My calendar' => 'Takvimim',
+ 'Search tasks' => 'Görevleri ara',
+ 'Back to the calendar' => 'Takvime geri dön',
+ 'Filters' => 'Filtreler',
+ 'Reset filters' => 'Filtreleri sıfırla',
+ 'My tasks due tomorrow' => 'Yarına tamamlanması gereken görevlerim',
+ 'Tasks due today' => 'Bugün tamamlanması gereken görevler',
+ 'Tasks due tomorrow' => 'Yarına tamamlanması gereken görevler',
+ 'Tasks due yesterday' => 'Dün tamamlanmış olması gereken görevler',
+ 'Closed tasks' => 'Kapatılmış görevler',
+ 'Open tasks' => 'Açık görevler',
+ 'Not assigned' => 'Atanmamış',
+ 'View advanced search syntax' => 'Gelişmiş arama kodlarını göster',
+ 'Overview' => 'Genel bakış',
+ 'Board/Calendar/List view' => 'Tablo/Takvim/Liste görünümü',
+ 'Switch to the board view' => 'Tablo görünümüne geç',
+ 'Switch to the calendar view' => 'Takvim görünümüne geç',
+ 'Switch to the list view' => 'Liste görünümüne geç',
+ 'Go to the search/filter box' => 'Arama/Filtreleme kutusuna git',
+ 'There is no activity yet.' => 'Henüz bir aktivite yok.',
+ 'No tasks found.' => 'Hiç görev bulunamadı.',
+ 'Keyboard shortcut: "%s"' => 'Klavye kısayolu: "%s"',
+ 'List' => 'Liste',
+ 'Filter' => 'Filtre',
+ 'Advanced search' => 'Gelişmiş arama',
+ 'Example of query: ' => 'Sorgu örneği',
+ 'Search by project: ' => 'Projeye göre ara',
+ 'Search by column: ' => 'Sütuna göre ara',
+ 'Search by assignee: ' => 'Atanana göre ara',
+ 'Search by color: ' => 'Renge göre ara',
+ 'Search by category: ' => 'Kategoriye göre ara',
+ 'Search by description: ' => 'Açıklamaya göre ara',
+ 'Search by due date: ' => 'Tamamlanma tarihine göre ara',
+ 'Lead and Cycle time for "%s"' => '"%s" için teslim ve çevrim süresi',
+ 'Average time spent into each column for "%s"' => '"%s" için her bir sütunda geçirilen ortalama zaman',
+ 'Average time spent into each column' => 'Her bir sütunda geçirilen ortalama zaman',
+ 'Average time spent' => 'Harcanan ortalama zaman',
+ 'This chart show the average time spent into each column for the last %d tasks.' => 'Bu grafik son %d görev için her bir sütunda geçirilen ortalama zamanı gösterir.',
+ 'Average Lead and Cycle time' => 'Ortalama teslim ve çevrim süresi',
+ 'Average lead time: ' => 'Ortalama teslim süresi',
+ 'Average cycle time: ' => 'Ortalama çevrim süresi',
+ 'Cycle Time' => 'Çevrim süresi',
+ 'Lead Time' => 'Teslim süresi',
+ 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Bu grafik son %d görev için zaman içinde gerçekleşen ortalama teslim ve çevrim sürelerini gösterir.',
+ 'Average time into each column' => 'Her bir sütunda ortalama zaman',
+ 'Lead and cycle time' => 'Teslim ve çevrim süresi',
+ 'Lead time: ' => 'Teslim süresi: ',
+ 'Cycle time: ' => 'Çevrim süresi: ',
+ 'Time spent into each column' => 'Her sütunda harcanan zaman',
+ 'The lead time is the duration between the task creation and the completion.' => 'Teslim süresi, görevin oluşturulması ile tamamlanması arasında geçen süredir.',
+ 'The cycle time is the duration between the start date and the completion.' => 'Çevrim süresi, görevin başlangıç tarihi ile tamamlanması arasında geçen süredir.',
+ 'If the task is not closed the current time is used instead of the completion date.' => 'Eğer görev henüz kapatılmamışsa, tamamlanma tarihi yerine şu anki tarih kullanılır.',
+ 'Set automatically the start date' => 'Başlangıç tarihini otomatik olarak belirle',
+ 'Edit Authentication' => 'Doğrulamayı düzenle',
+ 'Remote user' => 'Uzak kullanıcı',
+ 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Uzak kullanıcıların şifreleri Kanboard veritabanında saklanmaz, örnek: LDAP, Google ve Github hesapları',
+ 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Eğer giriş formuna erişimi engelleyi seçerseniz, giriş formuna girilen bilgiler gözardı edilir.',
+ 'New remote user' => 'Yeni uzak kullanıcı',
+ 'New local user' => 'Yeni yerel kullanıcı',
+ 'Default task color' => 'Varsayılan görev rengi',
+ 'This feature does not work with all browsers.' => 'Bu özellik tüm tarayıcılarla çalışmaz',
+ 'There is no destination project available.' => 'Seçilebilecek hedef proje yok.',
+ 'Trigger automatically subtask time tracking' => 'Altgörev zamanlayıcıyı otomatik olarak başlat',
+ 'Include closed tasks in the cumulative flow diagram' => 'Kümülatif akış diyagramında kapatılmış görevleri de dahil et',
+ 'Current swimlane: %s' => 'Geçerli kulvar: %s',
+ 'Current column: %s' => 'Geçerli sütun: %s',
+ 'Current category: %s' => 'Geçerli kategori: %s',
+ 'no category' => 'kategori yok',
+ 'Current assignee: %s' => 'Geçerli atanan: %s',
+ 'not assigned' => 'atanmamış',
+ 'Author:' => 'Yazar',
+ 'contributors' => 'Katkı sağlayanlar',
+ 'License:' => 'Lisans:',
+ 'License' => 'Lisans',
+ 'Enter the text below' => 'Aşağıdaki metni girin',
+ 'Gantt chart for %s' => '%s için Gantt diyagramı',
+ 'Sort by position' => 'Pozisyona göre sırala',
+ 'Sort by date' => 'Tarihe göre sırala',
+ 'Add task' => 'Görev ekle',
+ 'Start date:' => 'Başlangıç tarihi:',
+ 'Due date:' => 'Tamamlanması gereken tarih:',
+ 'There is no start date or due date for this task.' => 'Bu görev için başlangıç veya tamamlanması gereken tarih yok.',
+ 'Moving or resizing a task will change the start and due date of the task.' => 'Bir görevin boyutunu değiştirmek, görevin başlangıç ve tamamlanması gereken tarihlerini değiştirir.',
+ 'There is no task in your project.' => 'Projenizde hiç görev yok.',
+ 'Gantt chart' => 'Gantt diyagramı',
+ 'People who are project managers' => 'Proje yöneticisi olan kişiler',
+ 'People who are project members' => 'Proje üyesi olan kişiler',
// 'NOK - Norwegian Krone' => '',
- // 'Show this column' => '',
- // 'Hide this column' => '',
- // 'open file' => '',
- // 'End date' => '',
- // 'Users overview' => '',
- // 'Managers' => '',
- // 'Members' => '',
- // 'Shared project' => '',
- // 'Project managers' => '',
- // 'Project members' => '',
- // 'Gantt chart for all projects' => '',
- // 'Projects list' => '',
- // 'Gantt chart for this project' => '',
- // 'Project board' => '',
- // 'End date:' => '',
- // 'There is no start date or end date for this project.' => '',
- // 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
- // 'Link type' => '',
- // 'Change task color when using a specific task link' => '',
- // 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
- // 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
- // 'Documentation: %s' => '',
- // 'Switch to the Gantt chart view' => '',
- // 'Reset the search/filter box' => '',
- // 'Documentation' => '',
- // 'Table of contents' => '',
- // 'Gantt' => '',
- // 'Help with project permissions' => '',
- // 'Author' => '',
- // 'Version' => '',
- // 'Plugins' => '',
- // 'There is no plugin loaded.' => '',
- // 'Set maximum column height' => '',
- // 'Remove maximum column height' => '',
- // 'My notifications' => '',
- // 'Custom filters' => '',
- // 'Your custom filter have been created successfully.' => '',
- // 'Unable to create your custom filter.' => '',
- // 'Custom filter removed successfully.' => '',
- // 'Unable to remove this custom filter.' => '',
- // 'Edit custom filter' => '',
- // 'Your custom filter have been updated successfully.' => '',
- // 'Unable to update custom filter.' => '',
- // 'Web' => '',
- // 'New attachment on task #%d: %s' => '',
- // 'New comment on task #%d' => '',
- // 'Comment updated on task #%d' => '',
- // 'New subtask on task #%d' => '',
- // 'Subtask updated on task #%d' => '',
- // 'New task #%d: %s' => '',
- // 'Task updated #%d' => '',
- // 'Task #%d closed' => '',
- // 'Task #%d opened' => '',
- // 'Column changed for task #%d' => '',
- // 'New position for task #%d' => '',
- // 'Swimlane changed for task #%d' => '',
- // 'Assignee changed on task #%d' => '',
- // '%d overdue tasks' => '',
- // 'Task #%d is overdue' => '',
- // 'No new notifications.' => '',
- // 'Mark all as read' => '',
- // 'Mark as read' => '',
- // 'Total number of tasks in this column across all swimlanes' => '',
- // 'Collapse swimlane' => '',
- // 'Expand swimlane' => '',
- // 'Add a new filter' => '',
- // 'Share with all project members' => '',
- // 'Shared' => '',
- // 'Owner' => '',
- // 'Unread notifications' => '',
- // 'My filters' => '',
- // 'Notification methods:' => '',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'Show this column' => 'Bu sütunu göster',
+ 'Hide this column' => 'Bu sütunu gizle',
+ 'open file' => 'dosyayı aç',
+ 'End date' => 'Bitiş tarihi',
+ 'Users overview' => 'Kullanıcılara genel bakış',
+ 'Members' => 'Üyeler',
+ 'Shared project' => 'Paylaşılan proje',
+ 'Project managers' => 'Proje yöneticileri',
+ 'Gantt chart for all projects' => 'Tüm projeler için Gantt diyagramı',
+ 'Projects list' => 'Proje listesi',
+ 'Gantt chart for this project' => 'Bu proje için Gantt diyagramı',
+ 'Project board' => 'Proje tablosu',
+ 'End date:' => 'Bitiş tarihi:',
+ 'There is no start date or end date for this project.' => 'Bu proje için başlangıç veya bitiş tarihi yok.',
+ 'Projects Gantt chart' => 'Projeler Gantt diyagramı',
+ 'Link type' => 'Bağlantı türü',
+ 'Change task color when using a specific task link' => 'Belirli bir görev bağlantısı kullanıldığında görevin rengini değiştir',
+ 'Task link creation or modification' => 'Görev bağlantısı oluşturulması veya değiştirilmesi',
+ 'Milestone' => 'Kilometre taşı',
+ 'Documentation: %s' => 'Dokümantasyon: %s',
+ 'Switch to the Gantt chart view' => 'Gantt diyagramı görünümüne geç',
+ 'Reset the search/filter box' => 'Arama/Filtre kutusunu sıfırla',
+ 'Documentation' => 'Dokümantasyon',
+ 'Table of contents' => 'İçindekiler',
+ 'Gantt' => 'Gantt',
+ 'Author' => 'Yazar',
+ 'Version' => 'Versiyon',
+ 'Plugins' => 'Eklentiler',
+ 'There is no plugin loaded.' => 'Yüklenmiş bir eklendi yok',
+ 'Set maximum column height' => 'Maksimum sütun yüksekliğini belirle',
+ 'Remove maximum column height' => 'Maksimum sütun yüksekliğini iptal et',
+ 'My notifications' => 'Bildirimlerim',
+ 'Custom filters' => 'Özel filtreler',
+ 'Your custom filter have been created successfully.' => 'Özel filtreleriniz başarıyla oluşturuldu.',
+ 'Unable to create your custom filter.' => 'Özel filtreniz oluşturulamadı.',
+ 'Custom filter removed successfully.' => 'Özel filtreniz başarıyla silindi.',
+ 'Unable to remove this custom filter.' => 'Bu özel filtre silinemiyor.',
+ 'Edit custom filter' => 'Özel filtreyi düzenle',
+ 'Your custom filter have been updated successfully.' => 'Özel filtreleriniz başarıyla güncellendi.',
+ 'Unable to update custom filter.' => 'Özel filtre güncellenemiyor.',
+ 'Web' => 'İnternet',
+ 'New attachment on task #%d: %s' => '#%d görevinde yeni dosya: %s',
+ 'New comment on task #%d' => '#%d görevinde yeni yorum',
+ 'Comment updated on task #%d' => '#%d görevinde yorum güncellendi',
+ 'New subtask on task #%d' => '#%d görevinde yeni alt görev',
+ 'Subtask updated on task #%d' => '#%d görevinde alt görev güncellendi',
+ 'New task #%d: %s' => 'Yeni #%d görevi: %s',
+ 'Task updated #%d' => '#%d görevi güncellendi',
+ 'Task #%d closed' => '#%d görevi kapatıldı',
+ 'Task #%d opened' => '#%d görevi oluşturuldu',
+ 'Column changed for task #%d' => '#%d görevinin sütunu değişti',
+ 'New position for task #%d' => '#%d görevinin konumu değişti',
+ 'Swimlane changed for task #%d' => '#%d görevinin kulvarı değişti',
+ 'Assignee changed on task #%d' => '#%d görevine atanan değişti',
+ '%d overdue tasks' => '%d gecikmiş görev',
+ 'Task #%d is overdue' => '#%d görevi gecikti',
+ 'No new notifications.' => 'Yeni bildirim yok.',
+ 'Mark all as read' => 'Tümünü okunmuş olarak işaretle',
+ 'Mark as read' => 'Okunmuş olarak işaretle',
+ 'Total number of tasks in this column across all swimlanes' => 'Bu sutündaki görev sayısının tüm kulvarlardaki toplamı',
+ 'Collapse swimlane' => 'Kulvarı daralt',
+ 'Expand swimlane' => 'Kulvarı genişlet',
+ 'Add a new filter' => 'Yeni bir filtre ekle',
+ 'Share with all project members' => 'Tüm proje üyeleriyle paylaş',
+ 'Shared' => 'Paylaşılan',
+ 'Owner' => 'Sahibi',
+ 'Unread notifications' => 'Okunmamış bildirimler',
+ 'My filters' => 'Filtrelerim',
+ 'Notification methods:' => 'Bildirim yöntemleri:',
+ 'Import tasks from CSV file' => 'CSV dosyasından görevleri içeri aktar',
+ 'Unable to read your file' => 'Dosya okunamıyor',
+ '%d task(s) have been imported successfully.' => '%d görev başarıyla içeri aktarıldı.',
+ 'Nothing have been imported!' => 'Hiçbir şey içeri aktarılamadı!',
+ 'Import users from CSV file' => 'CSV dosyasından kullanıcıları içeri aktar',
+ '%d user(s) have been imported successfully.' => '%d kullanıcı başarıyla içeri aktarıldı.',
+ 'Comma' => 'Virgül',
+ 'Semi-colon' => 'Noktalı virgül',
+ 'Tab' => 'Tab',
+ 'Vertical bar' => 'Dikey çizgi',
+ 'Double Quote' => 'Tırnak işareti',
+ 'Single Quote' => 'Kesme işareti',
+ '%s attached a file to the task #%d' => '%s, #%d görevine bir dosya ekledi.',
+ 'There is no column or swimlane activated in your project!' => 'Projenizde etkinleştirilmiş hiç bir sütun veya kulvar yok!',
+ 'Append filter (instead of replacement)' => 'Fıltreye ekle (üzerine yazmak yerine)',
+ 'Append/Replace' => 'Ekle/Üzerine yaz',
+ 'Append' => 'Ekle',
+ 'Replace' => 'Üzerine yaz',
+ 'Import' => 'İçeri aktar',
+ 'change sorting' => 'sıralamayı değiştir',
+ 'Tasks Importation' => 'Görevleri içeri aktar',
+ 'Delimiter' => 'Ayırıcı',
+ 'Enclosure' => 'Enclosure',
+ 'CSV File' => 'CSV Dosyası',
+ 'Instructions' => 'Yönergeler',
+ 'Your file must use the predefined CSV format' => 'Dosyanız önceden belirlenmiş CSV formatını kullanmalı',
+ 'Your file must be encoded in UTF-8' => 'Dosyanız UTF-8 kodlamasında olmalı',
+ 'The first row must be the header' => 'İlk satır başlık olmalı',
+ 'Duplicates are not verified for you' => 'Çift girişler sizin için onaylanmamış',
+ 'The due date must use the ISO format: YYYY-MM-DD' => 'Tamamlanma tarihi ISO formatını kullanmalı: YYYY-MM-DD',
+ 'Download CSV template' => 'CSV taslağını indir',
+ 'No external integration registered.' => 'Hiç dış entegrasyon kaydedilmemiş.',
+ 'Duplicates are not imported' => 'Çift girişler içeri aktarılmaz',
+ 'Usernames must be lowercase and unique' => 'Kullanıcı adları küçük harf ve tekil olmalı',
+ 'Passwords will be encrypted if present' => 'Şifreler (eğer varsa) kriptolanır',
+ '%s attached a new file to the task %s' => '%s, %s görevine yeni dosya ekledi',
+ 'Assign automatically a category based on a link' => 'Bir bağlantıya göre otomatik olarak kategori ata',
+ 'BAM - Konvertible Mark' => 'BAM - Konvertible Mark',
+ 'Assignee Username' => 'Atanan kullanıcı adı',
+ 'Assignee Name' => 'Atanan İsmi',
+ 'Groups' => 'Gruplar',
+ 'Members of %s' => '%s in üyeleri',
+ 'New group' => 'Yeni grup',
+ 'Group created successfully.' => 'Grup başarıyla oluşturuldu.',
+ 'Unable to create your group.' => 'Grup oluşturulamadı.',
+ 'Edit group' => 'Grubu düzenle',
+ 'Group updated successfully.' => 'Grup başarıyla güncellendi.',
+ 'Unable to update your group.' => 'Grup güncellenemedi.',
+ 'Add group member to "%s"' => '"%s" e grup üyesi ekle',
+ 'Group member added successfully.' => 'Grup üyesi başarıyla eklendi.',
+ 'Unable to add group member.' => 'Grup üyesi eklenemedi.',
+ 'Remove user from group "%s"' => '"%s" grubundan kullanıcı çıkar',
+ 'User removed successfully from this group.' => 'Kullanıcı bu gruptan başarıyla çıkarıldı.',
+ 'Unable to remove this user from the group.' => 'Bu kullanıcı bu grubtan çıkarılamadı',
+ 'Remove group' => 'Grubu sil',
+ 'Group removed successfully.' => 'Grup başarıyla silindi.',
+ 'Unable to remove this group.' => 'Grup silinemedi.',
+ 'Project Permissions' => 'Proje izimleri',
+ 'Manager' => 'Yönetici',
+ 'Project Manager' => 'Proje yöneticisi',
+ 'Project Member' => 'Proje üyesi',
+ 'Project Viewer' => 'Proje izleyicisi',
+ 'Your account is locked for %d minutes' => 'Hesabınız %d dakika boyunca kilitlendi',
+ 'Invalid captcha' => 'Geçersiz captcha',
+ 'The name must be unique' => 'İsim tekil olmalı',
+ 'View all groups' => 'Tüm grupları görüntüle',
+ 'View group members' => 'Grup üyelerini görüntüle',
+ 'There is no user available.' => 'Uygun üye yok',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => '"%s" kullanıcısını "%s" grubundan çıkarmak istediğinize emin misiniz?',
+ 'There is no group.' => 'Hiç grup yok.',
+ 'External Id' => 'Harici Kimlik',
+ 'Add group member' => 'Grup üyesi ekle',
+ 'Do you really want to remove this group: "%s"?' => '"%s" grubunu silmek istediğinize emin misiniz?',
+ 'There is no user in this group.' => 'Bu grupta hiç kullanıcı yok.',
+ 'Remove this user' => 'Bu kullanıcıyı sil',
+ 'Permissions' => 'İzinler',
+ 'Allowed Users' => 'İzin verilen kullanıcı',
+ 'No user have been allowed specifically.' => 'Hiç bir kullanıcıya özel olarak izin verilmemiş.',
+ 'Role' => 'Rol',
+ 'Enter user name...' => 'Kullanıcı adını girin...',
+ 'Allowed Groups' => 'İzinli gruplar',
+ 'No group have been allowed specifically.' => 'Hiç bir gruba özel olarak izin verilmemiş',
+ 'Group' => 'Grup',
+ 'Group Name' => 'Grup adı',
+ 'Enter group name...' => 'Grup adını girin...',
+ 'Role:' => 'Rol:',
+ 'Project members' => 'Proje üyeleri',
+ 'Compare hours for "%s"' => '"%s" için saatleri karşılaştır',
+ '%s mentioned you in the task #%d' => '%s sizden #%d görevinde bahsetti',
+ '%s mentioned you in a comment on the task #%d' => '%s sizden #%d görevindeki bir yorumda bahsetti',
+ 'You were mentioned in the task #%d' => '#%d görevinde sizden bahsedildi',
+ 'You were mentioned in a comment on the task #%d' => '#%d görevindeki bir yorumda sizden bahsedildi',
+ 'Mentioned' => 'Bahsedilmiş',
+ 'Compare Estimated Time vs Actual Time' => 'Tahmini süre ile gerçekleşen süreyi karşılaştır',
+ 'Estimated hours: ' => 'Tahmini saat:',
+ 'Actual hours: ' => 'Gerçekleşen saat:',
+ 'Hours Spent' => 'Harcanan saat',
+ 'Hours Estimated' => 'Tahmini saat',
+ 'Estimated Time' => 'Tahmini süre',
+ 'Actual Time' => 'Gerçekleşen süre',
+ 'Estimated vs actual time' => 'Tahmini vs gerçekleşen süre',
+ // 'RUB - Russian Ruble' => '',
+ 'Assign the task to the person who does the action when the column is changed' => 'Sütun değiştirildiği zaman görevi eylemi gerçekleştiren kişiye ata',
+ 'Close a task in a specific column' => 'Belirli bir sütundaki görevi kapat',
+ 'Time-based One-time Password Algorithm' => 'Zamana bağlı tek kullanımlık şifre algoritması',
+ 'Two-Factor Provider: ' => 'Çift kademeli doğrulama sağlayıcısı',
+ 'Disable two-factor authentication' => 'Çift kademeli doğrulamayı devre dışı bırak',
+ 'Enable two-factor authentication' => 'Çift kademeli doğrulamayı etkinleştir',
+ 'There is no integration registered at the moment.' => 'Şu anda kayıtlı bir entegrasyon bulunmuyor.',
+ 'Password Reset for Kanboard' => 'Kanboard için şifre sıfırlama',
+ 'Forgot password?' => 'Şifrenizi mi unuttunuz?',
+ 'Enable "Forget Password"' => '"Şifremi unuttum" komutunu etkinleştir',
+ 'Password Reset' => 'Şifre sıfırlama',
+ 'New password' => 'Yeni şifre',
+ 'Change Password' => 'Şifreyi değiştir',
+ 'To reset your password click on this link:' => 'Şifrenizi sıfırlamak için bu linke tıklayın:',
+ 'Last Password Reset' => 'Son şifre sıfırlama',
+ 'The password has never been reinitialized.' => 'Şifre hiç bir zaman tekrar başlatılmamış.',
+ 'Creation' => 'Oluşturulma',
+ 'Expiration' => 'Sona erme',
+ 'Password reset history' => 'Şifre sıfırlama geçmişi',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '"%s" sütunu ve "%s" kulvarındaki tüm görevler başarıyla kapatıldı.',
+ 'Do you really want to close all tasks of this column?' => 'Bu sütundaki tüm görevleri kapatmak istediğinize emin misiniz?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '"%s" sütunu ve "%s" kulvarındaki %d görev kapatılacak.',
+ 'Close all tasks of this column' => 'Bu sütundaki tüm görevleri kapat',
+ // 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '',
+ // 'My dashboard' => '',
+ // 'My profile' => '',
+ // 'Project owner: ' => '',
+ // 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '',
+ // 'Project owner' => '',
+ // 'Those dates are useful for the project Gantt chart.' => '',
+ // 'Private projects do not have users and groups management.' => '',
+ // 'There is no project member.' => '',
+ // 'Priority' => '',
+ // 'Task priority' => '',
+ // 'General' => '',
+ // 'Dates' => '',
+ // 'Default priority' => '',
+ // 'Lowest priority' => '',
+ // 'Highest priority' => '',
+ // 'If you put zero to the low and high priority, this feature will be disabled.' => '',
+ // 'Close a task when there is no activity' => '',
+ // 'Duration in days' => '',
+ // 'Send email when there is no activity on a task' => '',
+ // 'List of external links' => '',
+ // 'Unable to fetch link information.' => '',
+ // 'Daily background job for tasks' => '',
+ // 'Auto' => '',
+ // 'Related' => '',
+ // 'Attachment' => '',
+ // 'Title not found' => '',
+ // 'Web Link' => '',
+ // 'External links' => '',
+ // 'Add external link' => '',
+ // 'Type' => '',
+ // 'Dependency' => '',
+ // 'Add internal link' => '',
+ // 'Add a new external link' => '',
+ // 'Edit external link' => '',
+ // 'External link' => '',
+ // 'Copy and paste your link here...' => '',
+ // 'URL' => '',
+ // 'There is no external link for the moment.' => '',
+ // 'Internal links' => '',
+ // 'There is no internal link for the moment.' => '',
+ // 'Assign to me' => '',
+ // 'Me' => '',
+ // 'Do not duplicate anything' => '',
+ // 'Projects management' => '',
+ // 'Users management' => '',
+ // 'Groups management' => '',
+ // 'Create from another project' => '',
+ // 'There is no subtask at the moment.' => '',
+ // 'open' => '',
+ // 'closed' => '',
+ // 'Priority:' => '',
+ // 'Reference:' => '',
+ // 'Complexity:' => '',
+ // 'Swimlane:' => '',
+ // 'Column:' => '',
+ // 'Position:' => '',
+ // 'Creator:' => '',
+ // 'Time estimated:' => '',
+ // '%s hours' => '',
+ // 'Time spent:' => '',
+ // 'Created:' => '',
+ // 'Modified:' => '',
+ // 'Completed:' => '',
+ // 'Started:' => '',
+ // 'Moved:' => '',
+ // 'Task #%d' => '',
+ // 'Sub-tasks' => '',
+ // 'Date and time format' => '',
+ // 'Time format' => '',
+ // 'Start date: ' => '',
+ // 'End date: ' => '',
+ // 'New due date: ' => '',
+ // 'Start date changed: ' => '',
+ // 'Disable private projects' => '',
+ // 'Do you really want to remove this custom filter: "%s"?' => '',
+ // 'Remove a custom filter' => '',
+ // 'User activated successfully.' => '',
+ // 'Unable to enable this user.' => '',
+ // 'User disabled successfully.' => '',
+ // 'Unable to disable this user.' => '',
+ // 'All files have been uploaded successfully.' => '',
+ // 'View uploaded files' => '',
+ // 'The maximum allowed file size is %sB.' => '',
+ // 'Choose files again' => '',
+ // 'Drag and drop your files here' => '',
+ // 'choose files' => '',
+ // 'View profile' => '',
+ // 'Two Factor' => '',
+ // 'Disable user' => '',
+ // 'Do you really want to disable this user: "%s"?' => '',
+ // 'Enable user' => '',
+ // 'Do you really want to enable this user: "%s"?' => '',
+ // 'Download' => '',
+ // 'Uploaded: %s' => '',
+ // 'Size: %s' => '',
+ // 'Uploaded by %s' => '',
+ // 'Filename' => '',
+ // 'Size' => '',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php
index cc7de564..55d5463d 100644
--- a/app/Locale/zh_CN/translations.php
+++ b/app/Locale/zh_CN/translations.php
@@ -20,15 +20,15 @@ return array(
'Red' => '红色',
'Orange' => '橘色',
'Grey' => '灰色',
- // 'Brown' => '',
- // 'Deep Orange' => '',
- // 'Dark Grey' => '',
- // 'Pink' => '',
- // 'Teal' => '',
- // 'Cyan' => '',
- // 'Lime' => '',
- // 'Light Green' => '',
- // 'Amber' => '',
+ 'Brown' => '褐色',
+ 'Deep Orange' => '橘红色',
+ 'Dark Grey' => '深灰色',
+ 'Pink' => '粉红色',
+ 'Teal' => '青色',
+ 'Cyan' => '蓝绿色',
+ 'Lime' => '绿黄色',
+ 'Light Green' => '浅绿色',
+ 'Amber' => '黄褐色',
'Save' => '保存',
'Login' => '登录',
'Official website:' => '官方网站:',
@@ -40,7 +40,7 @@ return array(
'All users' => '所有用户',
'Username' => '用户名',
'Password' => '密码',
- 'Administrator' => '管理员',
+ 'Administrator' => '超级管理员',
'Sign in' => '登录',
'Users' => '用户',
'No user' => '没有用户',
@@ -72,7 +72,6 @@ return array(
'Remove project' => '移除项目',
'Edit the board for "%s"' => '为"%s"修改看板',
'All projects' => '所有项目',
- 'Change columns' => '更改栏目',
'Add a new column' => '添加新栏目',
'Title' => '标题',
'Nobody assigned' => '无人被指派',
@@ -102,14 +101,11 @@ return array(
'Open a task' => '开一个任务',
'Do you really want to open this task: "%s"?' => '你确定要开这个任务吗?"%s"',
'Back to the board' => '回到看板',
- 'Created on %B %e, %Y at %k:%M %p' => '创建时间:%Y/%m/%d %H:%M',
- 'There is nobody assigned' => '无人负责',
+ 'There is nobody assigned' => '当前无人被指派',
'Column on the board:' => '看板上的栏目:',
- 'Status is open' => '开启状态',
- 'Status is closed' => '关闭状态',
'Close this task' => '关闭该任务',
'Open this task' => '开启该任务',
- 'There is no description.' => '无描述。',
+ 'There is no description.' => '当前没有描述。',
'Add a new task' => '添加新任务',
'The username is required' => '需要用户名',
'The maximum length is %d characters' => '最长%d个英文字符',
@@ -124,7 +120,6 @@ return array(
'The id is required' => '需要指定id',
'The project id is required' => '需要指定项目id',
'The project name is required' => '需要指定项目名称',
- 'This project must be unique' => '项目名称必须唯一',
'The title is required' => '需要指定标题',
'Settings saved successfully.' => '设置成功保存。',
'Unable to save your settings.' => '无法保存你的设置。',
@@ -159,10 +154,6 @@ return array(
'Work in progress' => '工作进行中',
'Done' => '完成',
'Application version:' => '应用程序版本:',
- 'Completed on %B %e, %Y at %k:%M %p' => '于%Y/%m/%d %H:%M 完成',
- '%B %e, %Y at %k:%M %p' => '%Y/%m/%d %H:%M',
- 'Date created' => '创建时间',
- 'Date completed' => '完成时间',
'Id' => '编号',
'%d closed tasks' => '%d个已关闭任务',
'No task for this project' => '该项目尚无任务',
@@ -175,13 +166,7 @@ return array(
'Complexity' => '复杂度',
'Task limit' => '任务限制',
'Task count' => '任务数',
- 'Edit project access list' => '编辑项目存取列表',
- 'Allow this user' => '允许该用户',
- 'Don\'t forget that administrators have access to everything.' => '别忘了管理员有一切的权限。',
- 'Revoke' => '撤销',
- 'List of authorized users' => '已授权的用户列表',
'User' => '用户',
- 'Nobody have access to this project.' => '无用户可以访问此项目.',
'Comments' => '评论',
'Write your text in Markdown' => '用Markdown格式编写',
'Leave a comment' => '留言',
@@ -192,9 +177,6 @@ return array(
'Edit this task' => '编辑该任务',
'Due Date' => '到期时间',
'Invalid date' => '无效日期',
- 'Must be done before %B %e, %Y' => '必须在%Y/%m/%d前完成',
- '%B %e, %Y' => '%Y/%m/%d',
- // '%b %e, %Y' => '',
'Automatic actions' => '自动动作',
'Your automatic action have been created successfully.' => '您的自动动作已成功创建',
'Unable to create your automatic action.' => '无法为您创建自动动作。',
@@ -225,8 +207,6 @@ return array(
'Assign a color to a specific user' => '为特定用户指派颜色',
'Column title' => '栏目名称',
'Position' => '位置',
- 'Move Up' => '往上移',
- 'Move Down' => '往下移',
'Duplicate to another project' => '复制到另一项目',
'Duplicate' => '复制',
'link' => '连接',
@@ -236,7 +216,6 @@ return array(
'Comment removed successfully.' => '评论成功移除。',
'Unable to remove this comment.' => '无法移除该评论。',
'Do you really want to remove this comment?' => '确定要移除评论吗?',
- 'Only administrators or the creator of the comment can access to this page.' => '只有管理员或评论创建者可以进入该页面。',
'Current password for the user "%s"' => '用户"%s"的当前密码',
'The current password is required' => '需要输入当前密码',
'Wrong password' => '密码错误',
@@ -248,7 +227,7 @@ return array(
'User agent' => '浏览器标识',
'Persistent connections' => '持续连接',
'No session.' => '无会话',
- 'Expiration date' => '过期',
+ 'Expiration date' => '过期日期',
'Remember Me' => '记住我',
'Creation date' => '创建日期',
'Everybody' => '所有人',
@@ -262,15 +241,11 @@ return array(
'%d comments' => '%d个评论',
'%d comment' => '%d个评论',
'Email address invalid' => '电子邮件地址无效',
- // 'Your external account is not linked anymore to your profile.' => '',
- // 'Unable to unlink your external account.' => '',
- // 'External authentication failed' => '',
- // 'Your external account is linked to your profile successfully.' => '',
+ 'Your external account is not linked anymore to your profile.' => '你的外部账户关联已解除',
+ 'Unable to unlink your external account.' => '无法关联到你的外部账户',
+ 'External authentication failed' => '外部认证失败',
+ 'Your external account is linked to your profile successfully.' => '你已成功关联到你的外部账户',
'Email' => '电子邮件',
- 'Link my Google Account' => '关联我的google帐号',
- 'Unlink my Google Account' => '去除我的google帐号关联',
- 'Login with my Google Account' => '用我的google帐号登录',
- 'Project not found.' => '未发现项目',
'Task removed successfully.' => '任务成功去除',
'Unable to remove this task.' => '无法移除该任务。',
'Remove a task' => '移除一个任务',
@@ -334,11 +309,7 @@ return array(
'Maximum size: ' => '大小上限:',
'Unable to upload the file.' => '无法上传文件',
'Display another project' => '显示其它项目',
- 'Login with my Github Account' => '用Github账号登录',
- 'Link my Github Account' => '链接Github账号',
- 'Unlink my Github Account' => '取消Github账号链接',
'Created by %s' => '创建者:%s',
- 'Last modified on %B %e, %Y at %k:%M %p' => '最后修改:%Y/%m/%d/ %H:%M',
'Tasks Export' => '任务导出',
'Tasks exportation for "%s"' => '导出"%s"的任务',
'Start Date' => '开始时间',
@@ -374,7 +345,6 @@ return array(
'I want to receive notifications only for those projects:' => '我仅需要收到下面项目的通知:',
'view the task on Kanboard' => '在看板中查看此任务',
'Public access' => '公开访问',
- 'User management' => '用户管理',
'Active tasks' => '活动任务',
'Disable public access' => '停止公开访问',
'Enable public access' => '开启公开访问',
@@ -397,18 +367,12 @@ return array(
'Email:' => '电子邮件:',
'Notifications:' => '通知:',
'Notifications' => '通知',
- 'Group:' => '群组:',
- 'Regular user' => '常规用户',
'Account type:' => '账户类型:',
'Edit profile' => '编辑属性',
'Change password' => '修改密码',
'Password modification' => '修改密码',
'External authentications' => '外部认证',
- 'Google Account' => '谷歌账号',
- 'Github Account' => 'Github 账号',
'Never connected.' => '从未连接。',
- 'No account linked.' => '未链接账号。',
- 'Account linked.' => '已经链接账号。',
'No external authentication enabled.' => '未启用外部认证。',
'Password modified successfully.' => '已经成功修改密码。',
'Unable to change the password.' => '无法修改密码。',
@@ -446,17 +410,10 @@ return array(
'%s changed the assignee of the task %s to %s' => '%s 将任务 %s 分配给 %s',
'New password for the user "%s"' => '用户"%s"的新密码',
'Choose an event' => '选择一个事件',
- 'Github commit received' => '收到了Github提交',
- 'Github issue opened' => '开启了Github问题报告',
- 'Github issue closed' => '关闭了Github问题报告',
- 'Github issue reopened' => '重新开启Github问题',
- 'Github issue assignee change' => 'Github问题负责人已经变更',
- 'Github issue label change' => 'Github任务标签修改',
'Create a task from an external provider' => '从外部创建任务',
'Change the assignee based on an external username' => '根据外部用户名修改任务分配',
'Change the category based on an external label' => '根据外部标签修改分类',
'Reference' => '参考',
- 'Reference: %s' => '参考:%s',
'Label' => '标签',
'Database' => '数据库',
'About' => '关于',
@@ -474,7 +431,6 @@ return array(
'Frequency in second (60 seconds by default)' => '频率,单位为秒(默认是60秒)',
'Frequency in second (0 to disable this feature, 10 seconds by default)' => '频率,单位为秒(设置为0停用此功能,默认是10秒)',
'Application URL' => '应用URL',
- 'Example: http://example.kanboard.net/ (used by email notifications)' => '例如:http://example.kanboard.net/ (用于电子邮件通知)',
'Token regenerated.' => '重新生成令牌',
'Date format' => '日期格式',
'ISO format is always accepted, example: "%s" and "%s"' => 'ISO 格式总是允许的,例如:"%s" 和 "%s"',
@@ -482,12 +438,9 @@ return array(
'This project is private' => '此项目为私有项目',
'Type here to create a new sub-task' => '要创建新的子任务,请在此输入',
'Add' => '添加',
- 'Estimated time: %s hours' => '预计时间:%s小时',
- 'Time spent: %s hours' => '花费时间:%s小时',
- 'Started on %B %e, %Y' => '开始时间: %Y/%m/%d %H:%M ',
'Start date' => '启动日期',
'Time estimated' => '预计时间',
- 'There is nothing assigned to you.' => '无任务指派给你。',
+ 'There is nothing assigned to you.' => '当前无任务指派给你。',
'My tasks' => '我的任务',
'Activity stream' => '动态记录',
'Dashboard' => '面板',
@@ -496,10 +449,7 @@ return array(
'Everybody have access to this project.' => '所有人都可以访问此项目',
'Webhooks' => '网络钩子',
'API' => '应用程序接口',
- 'Github webhooks' => 'Github 网络钩子',
- 'Help on Github webhooks' => 'Github 网络钩子帮助',
'Create a comment from an external provider' => '从外部创建一个评论',
- 'Github issue comment created' => '已经创建了Github问题评论',
'Project management' => '项目管理',
'My projects' => '我的项目',
'Columns' => '栏目',
@@ -517,7 +467,6 @@ return array(
'User repartition for "%s"' => '"%s"的用户分析',
'Clone this project' => '复制此项目',
'Column removed successfully.' => '成功删除了栏目。',
- 'Github Issue' => 'Github 任务报告',
'Not enough data to show the graph.' => '数据不足,无法绘图。',
'Previous' => '后退',
'The id must be an integer' => '编号必须为整数',
@@ -541,41 +490,27 @@ return array(
'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.' => '已经成功创建泳道。',
+ 'Active swimlanes' => '活动里程碑',
+ 'Add a new swimlane' => '添加新里程碑',
+ 'Change default swimlane' => '修改默认里程碑',
+ 'Default swimlane' => '默认里程碑',
+ 'Do you really want to remove this swimlane: "%s"?' => '确定要删除里程碑:"%s"?',
+ 'Inactive swimlanes' => '非活动里程碑',
+ 'Remove a 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.' => '成功更新了默认里程碑。',
+ '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 提交',
- 'Gitlab issue opened' => '开启 Gitlab 问题',
- 'Gitlab issue closed' => '关闭 Gitlab 问题',
- 'Gitlab webhooks' => 'Gitlab 网络钩子',
- 'Help on Gitlab webhooks' => 'Gitlab 网络钩子帮助',
'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 问题',
'Subtask Id' => '子任务 Id',
'Subtasks' => '子任务',
'Subtasks Export' => '子任务导出',
@@ -589,7 +524,7 @@ return array(
'Calendar' => '日程表',
'Next' => '前进',
'#%d' => '#%d',
- 'All swimlanes' => '全部泳道',
+ 'All swimlanes' => '全部里程碑',
'All colors' => '全部颜色',
'Moved to column %s' => '移动到栏目 %s',
'Change description' => '修改描述',
@@ -598,14 +533,11 @@ return array(
'Edit column "%s"' => '编辑栏目"%s"',
'Select the new status of the subtask: "%s"' => '选择子任务的新状态:"%s"',
'Subtask timesheet' => '子任务时间',
- 'There is nothing to show.' => '无内容。',
+ 'There is nothing to show.' => '当前无内容可展示。',
'Time Tracking' => '时间记录',
'You already have one subtask in progress' => '你已经有了一个进行中的子任务',
'Which parts of the project do you want to duplicate?' => '要复制项目的哪些内容?',
- // 'Disallow login form' => '',
- 'Bitbucket commit received' => '收到Bitbucket提交',
- 'Bitbucket webhooks' => 'Bitbucket网络钩子',
- 'Help on Bitbucket webhooks' => 'Bitbucket网络钩子帮助',
+ 'Disallow login form' => '禁止登陆',
'Start' => '开始',
'End' => '结束',
'Task age in days' => '任务存在天数',
@@ -627,26 +559,25 @@ return array(
'Remove a link' => '删除关联',
'Task\'s links' => '任务的关联',
'The labels must be different' => '标签不能一样',
- 'There is no link.' => '没有关联',
+ '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' => '',
+ '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' => '<1h',
'%dh' => '%dh',
- // '%b %e' => '',
'Expand tasks' => '展开任务',
'Collapse tasks' => '收缩任务',
'Expand/collapse tasks' => '展开/收缩任务',
@@ -656,27 +587,24 @@ return array(
'Keyboard shortcuts' => '键盘快捷方式',
'Open board switcher' => '打开面板切换器',
'Application' => '应用程序',
- // 'since %B %e, %Y at %k:%M %p' => '',
'Compact view' => '紧凑视图',
'Horizontal scrolling' => '水平滚动',
'Compact/wide view' => '紧凑/宽视图',
'No results match:' => '无匹配结果:',
'Currency' => '货币',
- 'Files' => '文件',
- 'Images' => '图片',
'Private project' => '私人项目',
- // 'AUD - Australian Dollar' => '',
- // 'CAD - Canadian Dollar' => '',
- // 'CHF - Swiss Francs' => '',
+ 'AUD - Australian Dollar' => '澳元',
+ 'CAD - Canadian Dollar' => '加元',
+ 'CHF - Swiss Francs' => '瑞士法郎',
'Custom Stylesheet' => '自定义样式表',
'download' => '下载',
- // 'EUR - Euro' => '',
- // 'GBP - British Pound' => '',
- // 'INR - Indian Rupee' => '',
- // 'JPY - Japanese Yen' => '',
- // 'NZD - New Zealand Dollar' => '',
- // 'RSD - Serbian dinar' => '',
- // 'USD - US Dollar' => '',
+ 'EUR - Euro' => '欧元',
+ 'GBP - British Pound' => '英镑',
+ 'INR - Indian Rupee' => '印度卢比',
+ 'JPY - Japanese Yen' => '日元',
+ 'NZD - New Zealand Dollar' => '新西兰元',
+ 'RSD - Serbian dinar' => '第纳尔',
+ 'USD - US Dollar' => '美元',
'Destination column' => '目标栏目',
'Move the task to another column when assigned to a user' => '指定负责人时移动到其它栏目',
'Move the task to another column when assignee is cleared' => '移除负责人时移动到其它栏目',
@@ -703,147 +631,123 @@ return array(
'The two factor authentication code is valid.' => '双重认证码正确。',
'Code' => '认证码',
'Two factor authentication' => '双重认证',
- 'Enable/disable two factor authentication' => '启用/禁用双重认证',
'This QR code contains the key URI: ' => '此二维码包含密码 URI:',
- 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => '将密码保存到 TOTP 软件(例如Google 认证或 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 通过KanBoard',
- 'uploaded by: %s' => '由: %s 上传',
- // 'uploaded on: %s' => '',
- // 'size: %s' => '',
- // 'Burndown chart for "%s"' => '',
- // 'Burndown chart' => '',
- // 'This chart show the task complexity over the time (Work Remaining).' => '',
+ 'Burndown chart for "%s"' => '燃尽图:"%s"',
+ 'Burndown chart' => '燃尽图',
+ 'This chart show the task complexity over the time (Work Remaining).' => '图表显示任务随时间(剩余时间)的复杂度',
'Screenshot taken %s' => '已截图 %s',
- // 'Add a screenshot' => '',
- // 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
- // 'Screenshot uploaded successfully.' => '',
- // 'SEK - Swedish Krona' => '',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
- // 'Identifier' => '',
- // 'Disable two factor authentication' => '',
- // 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
+ 'Add a screenshot' => '添加截图',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '获取截图并按CTRL+V或者⌘+V粘贴到这里',
+ 'Screenshot uploaded successfully.' => '截图上传成功',
+ 'SEK - Swedish Krona' => '瑞郎',
+ 'Identifier' => '标识符',
+ '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)' => '',
+ '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' => '循环性任务将按计划生成',
+ '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)' => '年',
'Calendar settings' => '日程设置',
- // 'Project calendar view' => '',
+ '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' => '',
+ '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 feed' => '日历订阅',
'Preferences' => '偏好',
'Security' => '安全',
- // 'Two factor authentication disabled' => '',
- // 'Two factor authentication enabled' => '',
- // 'Unable to update this user.' => '',
- // 'There is no user management for private projects.' => '',
- // 'User that will receive the email' => '',
+ 'Two factor authentication disabled' => '双重认证已禁用',
+ 'Two factor authentication enabled' => '双重认证已启用',
+ 'Unable to update this user.' => '无法更新此用户',
+ 'There is no user management for private projects.' => '私有项目下无用户可管理',
+ 'User that will receive the email' => '用户将收到邮件',
'Email subject' => '邮件主题',
'Date' => '日期',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
- // 'Add a comment log when moving the task between columns' => '',
- // 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
+ 'Add a comment log when moving the task between columns' => '当任务移动到任务栏时添加评论日志',
+ 'Move the task to another column when the category is changed' => '当任务分类改变时移动到任务栏',
+ 'Send a task by email to someone' => '发送任务邮件到用户',
'Reopen a task' => '重新开始一个任务',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
- // 'Column change' => '',
- // 'Position change' => '',
- // 'Swimlane change' => '',
- // 'Assignee change' => '',
- // '[%s] Overdue tasks' => '',
+ 'Column change' => '任务栏改变',
+ 'Position change' => '位置改变',
+ 'Swimlane change' => '里程碑改变',
+ 'Assignee change' => '指派人改变',
+ '[%s] Overdue tasks' => '[%s] 超期任务',
'Notification' => '通知',
- // '%s moved the task #%d to the first swimlane' => '',
- // '%s moved the task #%d to the swimlane "%s"' => '',
- // 'Swimlane' => '',
- // 'Gravatar' => '',
- // '%s moved the task %s to the first swimlane' => '',
- // '%s moved the task %s to the swimlane "%s"' => '',
- // 'This report contains all subtasks information for the given date range.' => '',
- // 'This report contains all tasks information for the given date range.' => '',
- // 'Project activities for %s' => '',
- // 'view the board on Kanboard' => '',
- // 'The task have been moved to the first swimlane' => '',
- // 'The task have been moved to another swimlane:' => '',
- // 'Overdue tasks for the project "%s"' => '',
- // 'New title: %s' => '',
- // 'The task is not assigned anymore' => '',
- // 'New assignee: %s' => '',
- // 'There is no category now' => '',
- // 'New category: %s' => '',
- // 'New color: %s' => '',
- // 'New complexity: %d' => '',
- // 'The due date have been removed' => '',
- // 'There is no description anymore' => '',
- // 'Recurrence settings have been modified' => '',
- // 'Time spent changed: %sh' => '',
- // 'Time estimated changed: %sh' => '',
- // 'The field "%s" have been updated' => '',
- // 'The description have been modified' => '',
- // 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
- // 'I want to receive notifications for:' => '',
- // 'All tasks' => '',
- // 'Only for tasks assigned to me' => '',
- // 'Only for tasks created by me' => '',
- // 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
+ '%s moved the task #%d to the first swimlane' => '%s将任务#%d移动到了首个里程碑',
+ '%s moved the task #%d to the swimlane "%s"' => '%s将任务#%d移动到了里程碑"%s"下',
+ 'Swimlane' => '里程碑',
+ 'Gravatar' => 'Gravatar头像',
+ '%s moved the task %s to the first swimlane' => '%s将任务%s移动到了首个里程碑',
+ '%s moved the task %s to the swimlane "%s"' => '%s将任务%s移动到了里程碑"%s"下',
+ 'This report contains all subtasks information for the given date range.' => '该报告包含了指定日期范围内的所有子任务信息。',
+ 'This report contains all tasks information for the given date range.' => '该报告包含了指定日期范围内的所有任务信息。',
+ 'Project activities for %s' => '%s的项目活动记录',
+ 'view the board on Kanboard' => '在看板上查看面板',
+ 'The task have been moved to the first swimlane' => '该任务已被移动到首个里程碑',
+ 'The task have been moved to another swimlane:' => '该任务已被移动到别的里程碑:',
+ 'Overdue tasks for the project "%s"' => '"%s"项目下的超期任务',
+ 'New title: %s' => '新标题:%s',
+ 'The task is not assigned anymore' => '该任务没有指派人',
+ 'New assignee: %s' => '新指派到:%s',
+ 'There is no category now' => '当前没有分类',
+ 'New category: %s' => '新分类:%s',
+ 'New color: %s' => '新颜色:%s',
+ 'New complexity: %d' => '新的复杂度:%d',
+ 'The due date have been removed' => '超期时间已被移除',
+ 'There is no description anymore' => '当前没有描述',
+ 'Recurrence settings have been modified' => '循环周期已被更改',
+ 'Time spent changed: %sh' => '时间花费已变更:%sh',
+ 'Time estimated changed: %sh' => '时间预估已变更:%sh',
+ 'The field "%s" have been updated' => '"%s"字段已更新',
+ 'The description have been modified' => '描述已更改',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => '你是否要移除所有子任务的同时父任务"%s"',
+ 'I want to receive notifications for:' => '我想接收以下相关通知:',
+ 'All tasks' => '所有任务',
+ 'Only for tasks assigned to me' => '所有指派给我的任务',
+ 'Only for tasks created by me' => '所有我创建的任务',
+ 'Only for tasks created by me and assigned to me' => '所有我创建的并且指派给我的任务',
// '%%Y-%%m-%%d' => '',
- // 'Total for all columns' => '',
- // 'You need at least 2 days of data to show the chart.' => '',
- // '<15m' => '',
- // '<30m' => '',
- 'Stop timer' => '停止定时器',
- 'Start timer' => '开启定时器',
+ 'Total for all columns' => '所有栏目下的',
+ 'You need at least 2 days of data to show the chart.' => '当前柱状图至少需要2天的数据。',
+ '<15m' => '小于15分钟',
+ '<30m' => '小于30分钟',
+ 'Stop timer' => '停止计时器',
+ 'Start timer' => '开启计时器',
'Add project member' => '添加项目成员',
'Enable notifications' => '打开通知',
'My activity stream' => '我的活动流',
@@ -861,31 +765,30 @@ return array(
'Not assigned' => '未指派',
'View advanced search syntax' => '查看高级搜索语法',
'Overview' => '概览',
- // '%b %e %Y' => '',
'Board/Calendar/List view' => '看板/日程/列表视图',
'Switch to the board view' => '切换到看板视图',
'Switch to the calendar view' => '切换到日程视图',
'Switch to the list view' => '切换到列表视图',
'Go to the search/filter box' => '前往搜索/过滤箱',
- 'There is no activity yet.' => '目前无任何活动.',
+ 'There is no activity yet.' => '当前无任何活动.',
'No tasks found.' => '没有找人任何任务.',
'Keyboard shortcut: "%s"' => '快捷键: "%s"',
'List' => '列表',
'Filter' => '过滤器',
'Advanced search' => '高级搜索',
'Example of query: ' => '查询示例: ',
- 'Search by project: ' => '按项目搜索: ',
- 'Search by column: ' => '按列搜索: ',
- 'Search by assignee: ' => '按被指派人搜索: ',
- 'Search by color: ' => '按颜色搜索: ',
- // 'Search by category: ' => '',
- // 'Search by description: ' => '',
- // 'Search by due date: ' => '',
+ 'Search by project: ' => '按项目: ',
+ 'Search by column: ' => '按任务栏: ',
+ 'Search by assignee: ' => '按被指派人: ',
+ 'Search by color: ' => '按颜色: ',
+ 'Search by category: ' => '按分类:',
+ 'Search by description: ' => '按描述:',
+ 'Search by due date: ' => '按超期时间:',
// 'Lead and Cycle time for "%s"' => '',
- // 'Average time spent into each column for "%s"' => '',
- // 'Average time spent into each column' => '',
- // 'Average time spent' => '',
- // 'This chart show the average time spent into each column for the last %d tasks.' => '',
+ 'Average time spent into each column for "%s"' => '"%s"在每个任务栏下平均花费时间',
+ 'Average time spent into each column' => '每个任务栏平均花费时间',
+ 'Average time spent' => '平均花费时间',
+ 'This chart show the average time spent into each column for the last %d tasks.' => '当前柱状图表示最新%d条任务在每个任务栏下的平均花费时间',
// 'Average Lead and Cycle time' => '',
// 'Average lead time: ' => '',
// 'Average cycle time: ' => '',
@@ -894,10 +797,6 @@ return array(
// 'This chart show the average lead and cycle time for the last %d tasks over the time.' => '',
// 'Average time into each column' => '',
// 'Lead and cycle time' => '',
- // 'Google Authentication' => '',
- // 'Help on Google authentication' => '',
- // 'Github Authentication' => '',
- // 'Help on Github authentication' => '',
// 'Lead time: ' => '',
// 'Cycle time: ' => '',
// 'Time spent into each column' => '',
@@ -905,26 +804,20 @@ return array(
// 'The cycle time is the duration between the start date and the completion.' => '',
// 'If the task is not closed the current time is used instead of the completion date.' => '',
// 'Set automatically the start date' => '',
- // 'Edit Authentication' => '',
- // 'Google Id' => '',
- // 'Github Id' => '',
- // 'Remote user' => '',
- // 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '',
- // 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '',
- // 'By @%s on Gitlab' => '',
- // 'Gitlab issue comment created' => '',
- // 'New remote user' => '',
- // 'New local user' => '',
+ 'Edit Authentication' => '编辑认证信息',
+ 'Remote user' => '远程用户',
+ 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => '远程用户不会在看板数据库保存密码,例如:LDAP,GOOGLE,GitHub。',
+ 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => '如果选中“禁止登陆来自”,登陆表单内的验证信息将被忽略。',
+ 'New remote user' => '新加远程用户',
+ 'New local user' => '新加本地用户',
'Default task color' => '默认任务颜色',
- // 'Hide sidebar' => '',
- // 'Expand sidebar' => '',
- // 'This feature does not work with all browsers.' => '',
- // 'There is no destination project available.' => '',
+ 'This feature does not work with all browsers.' => '本功能只在部分浏览器下工作正常。',
+ 'There is no destination project available.' => '当前没有目标项目可用',
'Trigger automatically subtask time tracking' => '自动跟踪子任务时间',
'Include closed tasks in the cumulative flow diagram' => '在累计流程图中包含已关闭任务',
- // 'Current swimlane: %s' => '',
- // 'Current column: %s' => '',
- // 'Current category: %s' => '',
+ 'Current swimlane: %s' => '当前里程碑:%s',
+ 'Current column: %s' => '当前任务栏:%s',
+ 'Current category: %s' => '当前分类:%s',
'no category' => '无分类',
'Current assignee: %s' => '当前被指派人: %s',
'not assigned' => '未指派',
@@ -932,7 +825,6 @@ return array(
'contributors' => '贡献者',
'License:' => '授权许可:',
'License' => '授权许可',
- 'Project Administrator' => '项目管理者',
'Enter the text below' => '输入下方的文本',
'Gantt chart for %s' => '%s的甘特图',
'Sort by position' => '按位置排序',
@@ -940,128 +832,320 @@ return array(
'Add task' => '添加任务',
'Start date:' => '开始日期',
'Due date:' => '到期日期',
- // 'There is no start date or due date for this task.' => '',
- // 'Moving or resizing a task will change the start and due date of the task.' => '',
- // 'There is no task in your project.' => '',
+ 'There is no start date or due date for this task.' => '当前任务没有开始或结束时间。',
+ 'Moving or resizing a task will change the start and due date of the task.' => '移动或者重设任务将改变任务开始和结束时间。',
+ 'There is no task in your project.' => '当前项目还没有任务',
'Gantt chart' => '甘特图',
- // 'People who are project managers' => '',
- // 'People who are project members' => '',
- // 'NOK - Norwegian Krone' => '',
- // 'Show this column' => '',
- // 'Hide this column' => '',
- // 'open file' => '',
+ 'People who are project managers' => '项目管理员',
+ 'People who are project members' => '项目成员',
+ 'NOK - Norwegian Krone' => '克朗',
+ 'Show this column' => '显示任务栏',
+ 'Hide this column' => '隐藏任务栏',
+ 'open file' => '打开文件',
'End date' => '结束日期',
'Users overview' => '用户概览',
- // 'Managers' => '',
'Members' => '成员',
- // 'Shared project' => '',
- // 'Project managers' => '',
- // 'Project members' => '',
- // 'Gantt chart for all projects' => '',
- // 'Projects list' => '',
- // 'Gantt chart for this project' => '',
- // 'Project board' => '',
- // 'End date:' => '',
- // 'There is no start date or end date for this project.' => '',
- // 'Projects Gantt chart' => '',
- // 'Start date: %s' => '',
- // 'End date: %s' => '',
- // 'Link type' => '',
- // 'Change task color when using a specific task link' => '',
- // 'Task link creation or modification' => '',
- // 'Login with my Gitlab Account' => '',
- // 'Milestone' => '',
- // 'Gitlab Authentication' => '',
- // 'Help on Gitlab authentication' => '',
- // 'Gitlab Id' => '',
- // 'Gitlab Account' => '',
- // 'Link my Gitlab Account' => '',
- // 'Unlink my Gitlab Account' => '',
- // 'Documentation: %s' => '',
- // 'Switch to the Gantt chart view' => '',
- // 'Reset the search/filter box' => '',
- // 'Documentation' => '',
- // 'Table of contents' => '',
- // 'Gantt' => '',
- // 'Help with project permissions' => '',
- // 'Author' => '',
- // 'Version' => '',
- // 'Plugins' => '',
- // 'There is no plugin loaded.' => '',
- // 'Set maximum column height' => '',
- // 'Remove maximum column height' => '',
- // 'My notifications' => '',
- // 'Custom filters' => '',
- // 'Your custom filter have been created successfully.' => '',
- // 'Unable to create your custom filter.' => '',
- // 'Custom filter removed successfully.' => '',
- // 'Unable to remove this custom filter.' => '',
- // 'Edit custom filter' => '',
- // 'Your custom filter have been updated successfully.' => '',
- // 'Unable to update custom filter.' => '',
- // 'Web' => '',
- // 'New attachment on task #%d: %s' => '',
- // 'New comment on task #%d' => '',
- // 'Comment updated on task #%d' => '',
- // 'New subtask on task #%d' => '',
- // 'Subtask updated on task #%d' => '',
- // 'New task #%d: %s' => '',
- // 'Task updated #%d' => '',
- // 'Task #%d closed' => '',
- // 'Task #%d opened' => '',
- // 'Column changed for task #%d' => '',
- // 'New position for task #%d' => '',
- // 'Swimlane changed for task #%d' => '',
- // 'Assignee changed on task #%d' => '',
- // '%d overdue tasks' => '',
- // 'Task #%d is overdue' => '',
- // 'No new notifications.' => '',
- // 'Mark all as read' => '',
- // 'Mark as read' => '',
- // 'Total number of tasks in this column across all swimlanes' => '',
- // 'Collapse swimlane' => '',
- // 'Expand swimlane' => '',
- // 'Add a new filter' => '',
- // 'Share with all project members' => '',
- // 'Shared' => '',
- // 'Owner' => '',
- // 'Unread notifications' => '',
- // 'My filters' => '',
- // 'Notification methods:' => '',
- // 'Import tasks from CSV file' => '',
- // 'Unable to read your file' => '',
- // '%d task(s) have been imported successfully.' => '',
- // 'Nothing have been imported!' => '',
- // 'Import users from CSV file' => '',
- // '%d user(s) have been imported successfully.' => '',
- // 'Comma' => '',
- // 'Semi-colon' => '',
- // 'Tab' => '',
- // 'Vertical bar' => '',
- // 'Double Quote' => '',
- // 'Single Quote' => '',
- // '%s attached a file to the task #%d' => '',
- // 'There is no column or swimlane activated in your project!' => '',
- // 'Append filter (instead of replacement)' => '',
- // 'Append/Replace' => '',
- // 'Append' => '',
- // 'Replace' => '',
- // 'There is no notification method registered.' => '',
- // 'Import' => '',
- // 'change sorting' => '',
- // 'Tasks Importation' => '',
- // 'Delimiter' => '',
- // 'Enclosure' => '',
- // 'CSV File' => '',
- // 'Instructions' => '',
- // 'Your file must use the predefined CSV format' => '',
- // 'Your file must be encoded in UTF-8' => '',
- // 'The first row must be the header' => '',
- // 'Duplicates are not verified for you' => '',
- // 'The due date must use the ISO format: YYYY-MM-DD' => '',
- // 'Download CSV template' => '',
- // 'No external integration registered.' => '',
- // 'Duplicates are not imported' => '',
- // 'Usernames must be lowercase and unique' => '',
- // 'Passwords will be encrypted if present' => '',
+ 'Shared project' => '公开项目',
+ 'Project managers' => '项目管理员',
+ 'Gantt chart for all projects' => '所有项目的甘特图',
+ 'Projects list' => '项目列表',
+ 'Gantt chart for this project' => '此项目的甘特图',
+ 'Project board' => '项目面板',
+ 'End date:' => '结束日期',
+ 'There is no start date or end date for this project.' => '当前项目没有开始或结束日期',
+ 'Projects Gantt chart' => '项目甘特图',
+ 'Link type' => '关联类型',
+ 'Change task color when using a specific task link' => '当任务关联到指定任务时改变颜色',
+ 'Task link creation or modification' => '任务链接创建或更新时间',
+ 'Milestone' => '里程碑',
+ 'Documentation: %s' => '文档:%s',
+ 'Switch to the Gantt chart view' => '切换到甘特图',
+ 'Reset the search/filter box' => '重置搜索/过滤框',
+ 'Documentation' => '文档',
+ 'Table of contents' => '表内容',
+ 'Gantt' => '甘特图',
+ 'Author' => '作者',
+ 'Version' => '版本',
+ 'Plugins' => '插件',
+ 'There is no plugin loaded.' => '当前没有插件载入',
+ 'Set maximum column height' => '设置任务栏最大高度',
+ 'Remove maximum column height' => '移除任务栏最大高度',
+ 'My notifications' => '我的通知',
+ 'Custom filters' => '自定义过滤器',
+ 'Your custom filter have been created successfully.' => '成功创建过滤器',
+ 'Unable to create your custom filter.' => '无法创建过滤器',
+ 'Custom filter removed successfully.' => '成功删除过滤器',
+ 'Unable to remove this custom filter.' => '无法删除这个过滤器',
+ 'Edit custom filter' => '编辑过滤器',
+ 'Your custom filter have been updated successfully.' => '你的过滤器更新成功',
+ 'Unable to update custom filter.' => '无法更新过滤器',
+ 'Web' => 'web',
+ 'New attachment on task #%d: %s' => '任务#%d下的新附件:%s',
+ 'New comment on task #%d' => '任务#%d下的新评论',
+ 'Comment updated on task #%d' => '任务#%d的评论已更新',
+ 'New subtask on task #%d' => '任务#%d下新的子任务',
+ 'Subtask updated on task #%d' => '任务#%d下的子任务已更新',
+ 'New task #%d: %s' => '新任务#%d:%s',
+ 'Task updated #%d' => '任务#%d已更新',
+ 'Task #%d closed' => '任务#%d已关闭',
+ 'Task #%d opened' => '任务#%d已打开',
+ 'Column changed for task #%d' => '任务#%d的任务栏已改变',
+ 'New position for task #%d' => '任务#%d的新状态',
+ 'Swimlane changed for task #%d' => '任务#%d的里程碑已改变',
+ 'Assignee changed on task #%d' => '任务#%d的指派人已改变',
+ '%d overdue tasks' => '%d条超期任务',
+ 'Task #%d is overdue' => '任务#%d已超期',
+ 'No new notifications.' => '没有新通知',
+ 'Mark all as read' => '标记所有为已读',
+ 'Mark as read' => '标记为已读',
+ 'Total number of tasks in this column across all swimlanes' => '此任务栏下的任务数(跨里程碑)',
+ 'Collapse swimlane' => '收起里程碑',
+ 'Expand swimlane' => '展开里程碑',
+ 'Add a new filter' => '添加新过滤器',
+ 'Share with all project members' => '对项目所有成员共享',
+ 'Shared' => '共享',
+ 'Owner' => '所有人',
+ 'Unread notifications' => '未读通知',
+ 'My filters' => '我的过滤器',
+ 'Notification methods:' => '通知提醒方式:',
+ 'Import tasks from CSV file' => '从CSV文件导入任务',
+ 'Unable to read your file' => '无法读取文件',
+ '%d task(s) have been imported successfully.' => '成功导入%d条任务。',
+ 'Nothing have been imported!' => '没有信息被导入!',
+ 'Import users from CSV file' => '从CSV文件导入用户',
+ '%d user(s) have been imported successfully.' => '成功导入%d个用户。',
+ 'Comma' => '逗号',
+ 'Semi-colon' => '分号',
+ 'Tab' => '制表符',
+ 'Vertical bar' => '竖线',
+ 'Double Quote' => '双引号',
+ 'Single Quote' => '单引号',
+ '%s attached a file to the task #%d' => '%s添加文件到任务#%d',
+ 'There is no column or swimlane activated in your project!' => '当前项目没有活动任务栏或里程碑',
+ 'Append filter (instead of replacement)' => '追加过滤',
+ 'Append/Replace' => '追加/替换',
+ 'Append' => '追加',
+ 'Replace' => '替换',
+ 'Import' => '导入',
+ 'change sorting' => '改变排序',
+ 'Tasks Importation' => '任务重要性',
+ 'Delimiter' => '分隔符',
+ 'Enclosure' => '附件',
+ 'CSV File' => 'CSV文件',
+ 'Instructions' => '操作指南',
+ 'Your file must use the predefined CSV format' => '文件必须为预定格式的CSV文件',
+ 'Your file must be encoded in UTF-8' => '文件编码必须为UTF-8',
+ 'The first row must be the header' => '第一行必须为表头',
+ 'Duplicates are not verified for you' => '无法验证重复信息',
+ 'The due date must use the ISO format: YYYY-MM-DD' => '超期日期必须为ISO格式:YYYY-MM-DD',
+ 'Download CSV template' => '下载CSV模板',
+ 'No external integration registered.' => '没有外部注册信息',
+ 'Duplicates are not imported' => '重复信息未导入',
+ 'Usernames must be lowercase and unique' => '用户名必须小写且唯一',
+ 'Passwords will be encrypted if present' => '密码将被加密',
+ '%s attached a new file to the task %s' => '"%s"添加了附件到任务"%s"',
+ 'Assign automatically a category based on a link' => '基于链接自动关联分类',
+ 'BAM - Konvertible Mark' => '波斯尼亚马克',
+ 'Assignee Username' => '指派用户名',
+ 'Assignee Name' => '指派名称',
+ 'Groups' => '用户组',
+ 'Members of %s' => '“%s”组成员',
+ 'New group' => '新加用户组',
+ 'Group created successfully.' => '用户组创建成功',
+ 'Unable to create your group.' => '无法创建你的用户组',
+ 'Edit group' => '编辑用户组',
+ 'Group updated successfully.' => '用户组更新成功',
+ 'Unable to update your group.' => '无法更新你的用户组',
+ 'Add group member to "%s"' => '添加到用户组"%s"',
+ 'Group member added successfully.' => '成功添加用户组成员',
+ 'Unable to add group member.' => '无法添加用户组成员',
+ 'Remove user from group "%s"' => '从"%s"组中移除用户',
+ 'User removed successfully from this group.' => '用户已从该用户组中删除',
+ 'Unable to remove this user from the group.' => '无法从该用户组中删除用户',
+ 'Remove group' => '删除用户组',
+ 'Group removed successfully.' => '用户组已删除',
+ 'Unable to remove this group.' => '无法删除该用户组',
+ 'Project Permissions' => '项目权限',
+ 'Manager' => '管理员',
+ 'Project Manager' => '项目管理员',
+ 'Project Member' => '项目成员',
+ 'Project Viewer' => '项目观察员',
+ 'Your account is locked for %d minutes' => '你的账户被锁定%d分钟',
+ 'Invalid captcha' => '验证码无效',
+ 'The name must be unique' => '请确保用户名唯一',
+ 'View all groups' => '查看所有用户组',
+ 'View group members' => '查看该组所有用户',
+ 'There is no user available.' => '当前没有有效用户',
+ 'Do you really want to remove the user "%s" from the group "%s"?' => '你确定把用户"%s"从"%s"中移除?',
+ 'There is no group.' => '当前没有用户组',
+ 'External Id' => '关联ID',
+ 'Add group member' => '添加用户组成员',
+ 'Do you really want to remove this group: "%s"?' => '你真的想要移除用户组:"%s"?',
+ 'There is no user in this group.' => '当前用户组下没有成员',
+ 'Remove this user' => '移除用户',
+ 'Permissions' => '权限',
+ 'Allowed Users' => '被允许的用户',
+ 'No user have been allowed specifically.' => '没有被允许的用户。',
+ 'Role' => '角色',
+ 'Enter user name...' => '输入用户名...',
+ 'Allowed Groups' => '被允许的用户组',
+ 'No group have been allowed specifically.' => '没有被允许的用户组。',
+ 'Group' => '用户组',
+ 'Group Name' => '用户组名称',
+ 'Enter group name...' => '输入用户组名称...',
+ 'Role:' => '角色:',
+ 'Project members' => '项目成员',
+ 'Compare hours for "%s"' => '比较"%s"的时间',
+ '%s mentioned you in the task #%d' => '%s在任务#%d里提及你',
+ '%s mentioned you in a comment on the task #%d' => '%s在任务#%d的评论里提及你',
+ 'You were mentioned in the task #%d' => '你在任务#%d里被提及',
+ 'You were mentioned in a comment on the task #%d' => '你在任务#%d的评论里被提及',
+ 'Mentioned' => '被提及',
+ 'Compare Estimated Time vs Actual Time' => '对比预估时间VS实际时间',
+ 'Estimated hours: ' => '预估小时数',
+ 'Actual hours: ' => '实际小时数',
+ 'Hours Spent' => '花费小时数',
+ 'Hours Estimated' => '预估小时数',
+ 'Estimated Time' => '预估时间',
+ 'Actual Time' => '实际时间',
+ 'Estimated vs actual time' => '预估时间 VS 实际时间',
+ 'RUB - Russian Ruble' => '卢布',
+ 'Assign the task to the person who does the action when the column is changed' => '当任务所属任务栏改变时分配到指定用户',
+ 'Close a task in a specific column' => '关闭指定任务栏下的任务',
+ 'Time-based One-time Password Algorithm' => '基于时间的一次性密码算法',
+ 'Two-Factor Provider: ' => '双重认证提供商',
+ 'Disable two-factor authentication' => '禁用双重认证',
+ 'Enable two-factor authentication' => '启用双重认证',
+ 'There is no integration registered at the moment.' => '当前没有注册关联',
+ 'Password Reset for Kanboard' => '重置kanboard密码',
+ 'Forgot password?' => '忘记密码?',
+ 'Enable "Forget Password"' => '启用找回密码',
+ 'Password Reset' => '密码重置',
+ 'New password' => '新密码',
+ 'Change Password' => '更改密码',
+ 'To reset your password click on this link:' => '点击此链接重置你的密码',
+ 'Last Password Reset' => '上次密码重置',
+ 'The password has never been reinitialized.' => '密码从未被重新初始化',
+ 'Creation' => '创建时间',
+ 'Expiration' => '过期时间',
+ 'Password reset history' => '密码重置历史',
+ 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => '任务栏 "%s" 和 里程碑 "%s" 下的所有任务已关闭',
+ 'Do you really want to close all tasks of this column?' => '你确定要关闭此任务栏下的所有任务?',
+ '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '任务栏 "%s" 和 里程碑 "%s" 下 %d 条任务将被关闭。',
+ 'Close all tasks of this column' => '关闭此任务栏下的所有任务',
+ 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => '没有插件注册到项目通知接口,你仍然可以在你个人设置里启用单独的通知',
+ 'My dashboard' => '我的控制台',
+ 'My profile' => '我的个人信息',
+ 'Project owner: ' => '项目负责人',
+ 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => '项目标识符是可选的,且只能是数字或字母组成,例如:MYPROJECT。',
+ 'Project owner' => '项目负责人',
+ 'Those dates are useful for the project Gantt chart.' => '那些时间对于项目甘特图非常有用',
+ 'Private projects do not have users and groups management.' => '私有项目下没有成员或组可管理',
+ 'There is no project member.' => '当前没有项目成员',
+ 'Priority' => '优先级',
+ 'Task priority' => '任务优先级',
+ 'General' => '常用',
+ 'Dates' => '时间',
+ 'Default priority' => '默认优先级',
+ 'Lowest priority' => '最低优先级',
+ 'Highest priority' => '最高优先级',
+ 'If you put zero to the low and high priority, this feature will be disabled.' => '如果你为优先级填0,将禁用本功能',
+ 'Close a task when there is no activity' => '当任务没有活动记录时关闭任务',
+ 'Duration in days' => '持续天数',
+ 'Send email when there is no activity on a task' => '当任务没有活动记录时发送邮件',
+ 'List of external links' => '列出外部关联',
+ 'Unable to fetch link information.' => '无法获取关联信息',
+ 'Daily background job for tasks' => '每日后台任务',
+ 'Auto' => '自动',
+ 'Related' => '相关的',
+ 'Attachment' => '附件',
+ 'Title not found' => '标题未找到',
+ 'Web Link' => '网页链接',
+ 'External links' => '外部关联',
+ 'Add external link' => '添加外部关联',
+ 'Type' => '类型',
+ 'Dependency' => '依赖',
+ 'Add internal link' => '添加内部关联',
+ 'Add a new external link' => '添加新的外部关联',
+ 'Edit external link' => '编辑外部关联',
+ 'External link' => '外部关联',
+ 'Copy and paste your link here...' => '复制并粘贴链接到当前位置...',
+ 'URL' => 'URL',
+ 'There is no external link for the moment.' => '当前没有外部关联。',
+ 'Internal links' => '内部关联',
+ 'There is no internal link for the moment.' => '当前没有内部关联。',
+ 'Assign to me' => '指派给我',
+ 'Me' => '我',
+ 'Do not duplicate anything' => '不再重复',
+ 'Projects management' => '项目管理',
+ 'Users management' => '用户管理',
+ 'Groups management' => '用户组管理',
+ 'Create from another project' => '从另一个项目中创建',
+ 'There is no subtask at the moment.' => '当前没有子任务。',
+ 'open' => '打开',
+ 'closed' => '已关闭',
+ 'Priority:' => '优先级:',
+ 'Reference:' => '引用:',
+ 'Complexity:' => '复杂度:',
+ 'Swimlane:' => '里程碑:',
+ 'Column:' => '列:',
+ 'Position:' => '位置:',
+ 'Creator:' => '创建者:',
+ 'Time estimated:' => '时间已过去:',
+ '%s hours' => '%s 小时',
+ 'Time spent:' => '时间消耗:',
+ 'Created:' => '已创建:',
+ 'Modified:' => '已修改:',
+ 'Completed:' => '已完成:',
+ 'Started:' => '已开始:',
+ 'Moved:' => '已移走',
+ 'Task #%d' => '任务#%d',
+ 'Sub-tasks' => '子任务',
+ 'Date and time format' => '时间和日期格式',
+ 'Time format' => '时间格式',
+ 'Start date: ' => '开始时间:',
+ 'End date: ' => '结束时间:',
+ 'New due date: ' => '新超期时间:',
+ 'Start date changed: ' => '开始时间已改变:',
+ 'Disable private projects' => '禁用私有项目',
+ 'Do you really want to remove this custom filter: "%s"?' => '你确定要移除这个自定义过滤器:"%s"?',
+ 'Remove a custom filter' => '移除自定义过滤器',
+ 'User activated successfully.' => '用户已激活。',
+ 'Unable to enable this user.' => '无法启用该用户。',
+ 'User disabled successfully.' => '用户已禁用。',
+ 'Unable to disable this user.' => '无法禁用该用户。',
+ 'All files have been uploaded successfully.' => '所有文件已成功上传。',
+ 'View uploaded files' => '查看已上传文件',
+ 'The maximum allowed file size is %sB.' => '最大上传尺寸 %sB',
+ 'Choose files again' => '重新选择文件',
+ 'Drag and drop your files here' => '拖放文件到这里',
+ 'choose files' => '选择文件',
+ 'View profile' => '查看个人信息',
+ 'Two Factor' => '双重认证',
+ 'Disable user' => '禁用用户',
+ 'Do you really want to disable this user: "%s"?' => '你确定要禁用该用户:"%s"?',
+ 'Enable user' => '启用用户',
+ 'Do you really want to enable this user: "%s"?' => '你确定要启用该用户:"%s"?',
+ 'Download' => '下载',
+ 'Uploaded: %s' => '上传:%s',
+ 'Size: %s' => '大小:%s',
+ 'Uploaded by %s' => '由%s上传',
+ 'Filename' => '文件名',
+ 'Size' => '大小',
+ // 'Column created successfully.' => '',
+ // 'Another column with the same name exists in the project' => '',
+ // 'Default filters' => '',
+ // 'Your board doesn\'t have any column!' => '',
+ // 'Change column position' => '',
+ // 'Switch to the project overview' => '',
+ // 'User filters' => '',
+ // 'Category filters' => '',
+ // 'Upload a file' => '',
+ // 'There is no attachment at the moment.' => '',
+ // 'View file' => '',
+ // 'Last activity' => '',
+ // 'Change subtask position' => '',
+ // 'This value must be greater than %d' => '',
+ // 'Another swimlane with the same name exists in the project' => '',
+ // 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => '',
);
diff --git a/app/Model/Acl.php b/app/Model/Acl.php
deleted file mode 100644
index 62f850cb..00000000
--- a/app/Model/Acl.php
+++ /dev/null
@@ -1,289 +0,0 @@
-<?php
-
-namespace Kanboard\Model;
-
-/**
- * Access List
- *
- * @package model
- * @author Frederic Guillot
- */
-class Acl extends Base
-{
- /**
- * Controllers and actions allowed from outside
- *
- * @access private
- * @var array
- */
- private $public_acl = array(
- 'auth' => array('login', 'check', 'captcha'),
- 'task' => array('readonly'),
- 'board' => array('readonly'),
- 'webhook' => '*',
- 'ical' => '*',
- 'feed' => '*',
- 'oauth' => array('google', 'github', 'gitlab'),
- );
-
- /**
- * Controllers and actions for project members
- *
- * @access private
- * @var array
- */
- private $project_member_acl = array(
- 'board' => '*',
- 'comment' => '*',
- 'file' => '*',
- 'project' => array('show'),
- 'listing' => '*',
- 'activity' => '*',
- 'subtask' => '*',
- 'task' => '*',
- 'taskduplication' => '*',
- 'taskcreation' => '*',
- 'taskmodification' => '*',
- 'taskstatus' => '*',
- 'tasklink' => '*',
- 'timer' => '*',
- 'customfilter' => '*',
- 'calendar' => array('show', 'project'),
- );
-
- /**
- * Controllers and actions for project managers
- *
- * @access private
- * @var array
- */
- private $project_manager_acl = array(
- 'action' => '*',
- 'analytic' => '*',
- 'category' => '*',
- 'column' => '*',
- 'export' => '*',
- 'taskimport' => '*',
- 'project' => array('edit', 'update', 'share', 'integrations', 'notifications', 'users', 'alloweverybody', 'allow', 'setowner', 'revoke', 'duplicate', 'disable', 'enable'),
- 'swimlane' => '*',
- 'gantt' => array('project', 'savetaskdate', 'task', 'savetask'),
- );
-
- /**
- * Controllers and actions for project admins
- *
- * @access private
- * @var array
- */
- private $project_admin_acl = array(
- 'project' => array('remove'),
- 'projectuser' => '*',
- 'gantt' => array('projects', 'saveprojectdate'),
- );
-
- /**
- * Controllers and actions for admins
- *
- * @access private
- * @var array
- */
- private $admin_acl = array(
- 'user' => array('index', 'create', 'save', 'remove', 'authentication'),
- 'userimport' => '*',
- 'config' => '*',
- 'link' => '*',
- 'currency' => '*',
- 'twofactor' => array('disable'),
- );
-
- /**
- * Extend ACL rules
- *
- * @access public
- * @param string $acl_name
- * @param aray $rules
- */
- public function extend($acl_name, array $rules)
- {
- $this->$acl_name = array_merge($this->$acl_name, $rules);
- }
-
- /**
- * Return true if the specified controller/action match the given acl
- *
- * @access public
- * @param array $acl Acl list
- * @param string $controller Controller name
- * @param string $action Action name
- * @return bool
- */
- public function matchAcl(array $acl, $controller, $action)
- {
- $controller = strtolower($controller);
- $action = strtolower($action);
- return isset($acl[$controller]) && $this->hasAction($action, $acl[$controller]);
- }
-
- /**
- * Return true if the specified action is inside the list of actions
- *
- * @access public
- * @param string $action Action name
- * @param mixed $action Actions list
- * @return bool
- */
- public function hasAction($action, $actions)
- {
- if (is_array($actions)) {
- return in_array($action, $actions);
- }
-
- return $actions === '*';
- }
-
- /**
- * Return true if the given action is public
- *
- * @access public
- * @param string $controller Controller name
- * @param string $action Action name
- * @return bool
- */
- public function isPublicAction($controller, $action)
- {
- return $this->matchAcl($this->public_acl, $controller, $action);
- }
-
- /**
- * Return true if the given action is for admins
- *
- * @access public
- * @param string $controller Controller name
- * @param string $action Action name
- * @return bool
- */
- public function isAdminAction($controller, $action)
- {
- return $this->matchAcl($this->admin_acl, $controller, $action);
- }
-
- /**
- * Return true if the given action is for project managers
- *
- * @access public
- * @param string $controller Controller name
- * @param string $action Action name
- * @return bool
- */
- public function isProjectManagerAction($controller, $action)
- {
- return $this->matchAcl($this->project_manager_acl, $controller, $action);
- }
-
- /**
- * Return true if the given action is for application managers
- *
- * @access public
- * @param string $controller Controller name
- * @param string $action Action name
- * @return bool
- */
- public function isProjectAdminAction($controller, $action)
- {
- return $this->matchAcl($this->project_admin_acl, $controller, $action);
- }
-
- /**
- * Return true if the given action is for project members
- *
- * @access public
- * @param string $controller Controller name
- * @param string $action Action name
- * @return bool
- */
- public function isProjectMemberAction($controller, $action)
- {
- return $this->matchAcl($this->project_member_acl, $controller, $action);
- }
-
- /**
- * Return true if the visitor is allowed to access to the given page
- * We suppose the user already authenticated
- *
- * @access public
- * @param string $controller Controller name
- * @param string $action Action name
- * @param integer $project_id Project id
- * @return bool
- */
- public function isAllowed($controller, $action, $project_id = 0)
- {
- // If you are admin you have access to everything
- if ($this->userSession->isAdmin()) {
- return true;
- }
-
- // If you access to an admin action, your are not allowed
- if ($this->isAdminAction($controller, $action)) {
- return false;
- }
-
- // Check project admin permissions
- if ($this->isProjectAdminAction($controller, $action)) {
- return $this->handleProjectAdminPermissions($project_id);
- }
-
- // Check project manager permissions
- if ($this->isProjectManagerAction($controller, $action)) {
- return $this->handleProjectManagerPermissions($project_id);
- }
-
- // Check project member permissions
- if ($this->isProjectMemberAction($controller, $action)) {
- return $project_id > 0 && $this->projectPermission->isMember($project_id, $this->userSession->getId());
- }
-
- // Other applications actions are allowed
- return true;
- }
-
- /**
- * Handle permission for project manager
- *
- * @access public
- * @param integer $project_id
- * @return boolean
- */
- public function handleProjectManagerPermissions($project_id)
- {
- if ($project_id > 0) {
- if ($this->userSession->isProjectAdmin()) {
- return $this->projectPermission->isMember($project_id, $this->userSession->getId());
- }
-
- return $this->projectPermission->isManager($project_id, $this->userSession->getId());
- }
-
- return false;
- }
-
- /**
- * Handle permission for project admins
- *
- * @access public
- * @param integer $project_id
- * @return boolean
- */
- public function handleProjectAdminPermissions($project_id)
- {
- if (! $this->userSession->isProjectAdmin()) {
- return false;
- }
-
- if ($project_id > 0) {
- return $this->projectPermission->isMember($project_id, $this->userSession->getId());
- }
-
- return true;
- }
-}
diff --git a/app/Model/Action.php b/app/Model/Action.php
index ba74218f..4da2fb8f 100644
--- a/app/Model/Action.php
+++ b/app/Model/Action.php
@@ -2,14 +2,8 @@
namespace Kanboard\Model;
-use Kanboard\Integration\GitlabWebhook;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\BitbucketWebhook;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-
/**
- * Action model
+ * Action Model
*
* @package model
* @author Frederic Guillot
@@ -24,152 +18,38 @@ class Action extends Base
const TABLE = 'actions';
/**
- * SQL table name for action parameters
- *
- * @var string
- */
- const TABLE_PARAMS = 'action_has_params';
-
- /**
- * Extended actions
- *
- * @access private
- * @var array
- */
- private $actions = array();
-
- /**
- * Extend the list of default actions
- *
- * @access public
- * @param string $className
- * @param string $description
- * @return Action
- */
- public function extendActions($className, $description)
- {
- $this->actions[$className] = $description;
- return $this;
- }
-
- /**
- * Return the name and description of available actions
- *
- * @access public
- * @return array
- */
- public function getAvailableActions()
- {
- $values = array(
- 'TaskClose' => t('Close a task'),
- 'TaskOpen' => t('Open a task'),
- 'TaskAssignSpecificUser' => t('Assign the task to a specific user'),
- 'TaskAssignCurrentUser' => t('Assign the task to the person who does the action'),
- 'TaskDuplicateAnotherProject' => t('Duplicate the task to another project'),
- 'TaskMoveAnotherProject' => t('Move the task to another project'),
- 'TaskMoveColumnAssigned' => t('Move the task to another column when assigned to a user'),
- 'TaskMoveColumnUnAssigned' => t('Move the task to another column when assignee is cleared'),
- 'TaskAssignColorColumn' => t('Assign a color when the task is moved to a specific column'),
- 'TaskAssignColorUser' => t('Assign a color to a specific user'),
- 'TaskAssignColorCategory' => t('Assign automatically a color based on a category'),
- 'TaskAssignCategoryColor' => t('Assign automatically a category based on a color'),
- 'CommentCreation' => t('Create a comment from an external provider'),
- 'TaskCreation' => t('Create a task from an external provider'),
- 'TaskLogMoveAnotherColumn' => t('Add a comment log when moving the task between columns'),
- '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'),
- 'TaskEmail' => t('Send a task by email to someone'),
- 'TaskAssignColorLink' => t('Change task color when using a specific task link'),
- );
-
- $values = array_merge($values, $this->actions);
-
- asort($values);
-
- return $values;
- }
-
- /**
- * Return the name and description of available actions
- *
- * @access public
- * @return array
- */
- public function getAvailableEvents()
- {
- $values = array(
- TaskLink::EVENT_CREATE_UPDATE => t('Task link creation or modification'),
- Task::EVENT_MOVE_COLUMN => t('Move a task to another column'),
- Task::EVENT_UPDATE => t('Task modification'),
- Task::EVENT_CREATE => t('Task creation'),
- Task::EVENT_OPEN => t('Reopen a task'),
- Task::EVENT_CLOSE => t('Closing a task'),
- Task::EVENT_CREATE_UPDATE => t('Task creation or modification'),
- Task::EVENT_ASSIGNEE_CHANGE => t('Task assignee change'),
- GithubWebhook::EVENT_COMMIT => t('Github commit received'),
- GithubWebhook::EVENT_ISSUE_OPENED => t('Github issue opened'),
- GithubWebhook::EVENT_ISSUE_CLOSED => t('Github issue closed'),
- GithubWebhook::EVENT_ISSUE_REOPENED => t('Github issue reopened'),
- GithubWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE => t('Github issue assignee change'),
- GithubWebhook::EVENT_ISSUE_LABEL_CHANGE => t('Github issue label change'),
- GithubWebhook::EVENT_ISSUE_COMMENT => t('Github issue comment created'),
- GitlabWebhook::EVENT_COMMIT => t('Gitlab commit received'),
- GitlabWebhook::EVENT_ISSUE_OPENED => t('Gitlab issue opened'),
- GitlabWebhook::EVENT_ISSUE_CLOSED => t('Gitlab issue closed'),
- GitlabWebhook::EVENT_ISSUE_COMMENT => t('Gitlab issue comment created'),
- BitbucketWebhook::EVENT_COMMIT => t('Bitbucket commit received'),
- BitbucketWebhook::EVENT_ISSUE_OPENED => t('Bitbucket issue opened'),
- BitbucketWebhook::EVENT_ISSUE_CLOSED => t('Bitbucket issue closed'),
- BitbucketWebhook::EVENT_ISSUE_REOPENED => t('Bitbucket issue reopened'),
- BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE => t('Bitbucket issue assignee change'),
- BitbucketWebhook::EVENT_ISSUE_COMMENT => t('Bitbucket issue comment created'),
- );
-
- asort($values);
-
- return $values;
- }
-
- /**
- * Return the name and description of compatible actions
+ * Return actions and parameters for a given user
*
* @access public
- * @param string $action_name Action name
+ * @param integer $user_id
* @return array
*/
- public function getCompatibleEvents($action_name)
+ public function getAllByUser($user_id)
{
- $action = $this->load($action_name, 0, '');
- $compatible_events = $action->getCompatibleEvents();
- $events = array();
+ $project_ids = $this->projectPermission->getActiveProjectIds($user_id);
+ $actions = array();
- foreach ($this->getAvailableEvents() as $event_name => $event_description) {
- if (in_array($event_name, $compatible_events)) {
- $events[$event_name] = $event_description;
- }
+ if (! empty($project_ids)) {
+ $actions = $this->db->table(self::TABLE)->in('project_id', $project_ids)->findAll();
+ $params = $this->actionParameter->getAllByActions(array_column($actions, 'id'));
+ $this->attachParamsToActions($actions, $params);
}
- return $events;
+ return $actions;
}
/**
* Return actions and parameters for a given project
*
* @access public
- * @param $project_id
+ * @param integer $project_id
* @return array
*/
public function getAllByProject($project_id)
{
$actions = $this->db->table(self::TABLE)->eq('project_id', $project_id)->findAll();
-
- foreach ($actions as &$action) {
- $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action['id'])->findAll();
- }
-
- return $actions;
+ $params = $this->actionParameter->getAllByActions(array_column($actions, 'id'));
+ return $this->attachParamsToActions($actions, $params);
}
/**
@@ -181,63 +61,51 @@ class Action extends Base
public function getAll()
{
$actions = $this->db->table(self::TABLE)->findAll();
- $params = $this->db->table(self::TABLE_PARAMS)->findAll();
-
- foreach ($actions as &$action) {
- $action['params'] = array();
-
- foreach ($params as $param) {
- if ($param['action_id'] === $action['id']) {
- $action['params'][] = $param;
- }
- }
- }
-
- return $actions;
+ $params = $this->actionParameter->getAll();
+ return $this->attachParamsToActions($actions, $params);
}
/**
- * Get all required action parameters for all registered actions
+ * Fetch an action
*
* @access public
- * @return array All required parameters for all actions
+ * @param integer $action_id
+ * @return array
*/
- public function getAllActionParameters()
+ public function getById($action_id)
{
- $params = array();
+ $action = $this->db->table(self::TABLE)->eq('id', $action_id)->findOne();
- foreach ($this->getAll() as $action) {
- $action = $this->load($action['action_name'], $action['project_id'], $action['event_name']);
- $params += $action->getActionRequiredParameters();
+ if (! empty($action)) {
+ $action['params'] = $this->actionParameter->getAllByAction($action_id);
}
- return $params;
+ return $action;
}
/**
- * Fetch an action
+ * Attach parameters to actions
*
- * @access public
- * @param integer $action_id Action id
- * @return array Action data
+ * @access private
+ * @param array &$actions
+ * @param array &$params
+ * @return array
*/
- public function getById($action_id)
+ private function attachParamsToActions(array &$actions, array &$params)
{
- $action = $this->db->table(self::TABLE)->eq('id', $action_id)->findOne();
-
- if (! empty($action)) {
- $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action_id)->findAll();
+ foreach ($actions as &$action) {
+ $action['params'] = isset($params[$action['id']]) ? $params[$action['id']] : array();
}
- return $action;
+ return $actions;
}
/**
* Remove an action
*
* @access public
- * @param integer $action_id Action id
- * @return bool Success or not
+ * @param integer $action_id
+ * @return bool
*/
public function remove($action_id)
{
@@ -261,24 +129,16 @@ class Action extends Base
'action_name' => $values['action_name'],
);
- if (! $this->db->table(self::TABLE)->save($action)) {
+ if (! $this->db->table(self::TABLE)->insert($action)) {
$this->db->cancelTransaction();
return false;
}
$action_id = $this->db->getLastId();
- foreach ($values['params'] as $param_name => $param_value) {
- $action_param = array(
- 'action_id' => $action_id,
- 'name' => $param_name,
- 'value' => $param_value,
- );
-
- if (! $this->db->table(self::TABLE_PARAMS)->save($action_param)) {
- $this->db->cancelTransaction();
- return false;
- }
+ if (! $this->actionParameter->create($action_id, $values)) {
+ $this->db->cancelTransaction();
+ return false;
}
$this->db->closeTransaction();
@@ -287,42 +147,6 @@ class Action extends Base
}
/**
- * Load all actions and attach events
- *
- * @access public
- */
- public function attachEvents()
- {
- $actions = $this->getAll();
-
- foreach ($actions as $action) {
- $listener = $this->load($action['action_name'], $action['project_id'], $action['event_name']);
-
- foreach ($action['params'] as $param) {
- $listener->setParam($param['name'], $param['value']);
- }
-
- $this->container['dispatcher']->addListener($action['event_name'], array($listener, 'execute'));
- }
- }
-
- /**
- * Load an action
- *
- * @access public
- * @param string $name Action class name
- * @param integer $project_id Project id
- * @param string $event Event name
- * @return \Action\Base
- */
- public function load($name, $project_id, $event)
- {
- $className = $name{0}
- !== '\\' ? '\Kanboard\Action\\'.$name : $name;
- return new $className($this->container, $project_id, $event);
- }
-
- /**
* Copy actions from a project to another one (skip actions that cannot resolve parameters)
*
* @author Antonio Rabelo
@@ -344,15 +168,14 @@ class Action extends Base
);
if (! $this->db->table(self::TABLE)->insert($values)) {
- $this->container['logger']->debug('Action::duplicate => unable to create '.$action['action_name']);
$this->db->cancelTransaction();
continue;
}
$action_id = $this->db->getLastId();
- if (! $this->duplicateParameters($dst_project_id, $action_id, $action['params'])) {
- $this->container['logger']->debug('Action::duplicate => unable to copy parameters for '.$action['action_name']);
+ if (! $this->actionParameter->duplicateParameters($dst_project_id, $action_id, $action['params'])) {
+ $this->logger->error('Action::duplicate => skip action '.$action['action_name'].' '.$action['id']);
$this->db->cancelTransaction();
continue;
}
@@ -362,95 +185,4 @@ class Action extends Base
return true;
}
-
- /**
- * Duplicate action parameters
- *
- * @access public
- * @param integer $project_id
- * @param integer $action_id
- * @param array $params
- * @return boolean
- */
- public function duplicateParameters($project_id, $action_id, array $params)
- {
- foreach ($params as $param) {
- $value = $this->resolveParameters($param, $project_id);
-
- if ($value === false) {
- $this->container['logger']->debug('Action::duplicateParameters => unable to resolve '.$param['name'].'='.$param['value']);
- return false;
- }
-
- $values = array(
- 'action_id' => $action_id,
- 'name' => $param['name'],
- 'value' => $value,
- );
-
- if (! $this->db->table(self::TABLE_PARAMS)->insert($values)) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * Resolve action parameter values according to another project
- *
- * @author Antonio Rabelo
- * @access public
- * @param array $param Action parameter
- * @param integer $project_id Project to find the corresponding values
- * @return mixed
- */
- public function resolveParameters(array $param, $project_id)
- {
- switch ($param['name']) {
- case 'project_id':
- return $project_id;
- case 'category_id':
- return $this->category->getIdByName($project_id, $this->category->getNameById($param['value'])) ?: false;
- case 'src_column_id':
- case 'dest_column_id':
- case 'dst_column_id':
- case 'column_id':
- $column = $this->board->getColumn($param['value']);
-
- if (empty($column)) {
- return false;
- }
-
- return $this->board->getColumnIdByTitle($project_id, $column['title']) ?: false;
- case 'user_id':
- case 'owner_id':
- return $this->projectPermission->isMember($project_id, $param['value']) ? $param['value'] : false;
- default:
- return $param['value'];
- }
- }
-
- /**
- * Validate action creation
- *
- * @access public
- * @param array $values Required parameters to save an action
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('project_id', t('The project id is required')),
- new Validators\Integer('project_id', t('This value must be an integer')),
- new Validators\Required('event_name', t('This value is required')),
- new Validators\Required('action_name', t('This value is required')),
- new Validators\Required('params', t('This value is required')),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/ActionParameter.php b/app/Model/ActionParameter.php
new file mode 100644
index 00000000..53edcbc8
--- /dev/null
+++ b/app/Model/ActionParameter.php
@@ -0,0 +1,162 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Action Parameter Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ActionParameter extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'action_has_params';
+
+ /**
+ * Get all action params
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ $params = $this->db->table(self::TABLE)->findAll();
+ return $this->toDictionary($params);
+ }
+
+ /**
+ * Get all params for a list of actions
+ *
+ * @access public
+ * @param array $action_ids
+ * @return array
+ */
+ public function getAllByActions(array $action_ids)
+ {
+ $params = $this->db->table(self::TABLE)->in('action_id', $action_ids)->findAll();
+ return $this->toDictionary($params);
+ }
+
+ /**
+ * Build params dictionary
+ *
+ * @access private
+ * @param array $params
+ * @return array
+ */
+ private function toDictionary(array $params)
+ {
+ $result = array();
+
+ foreach ($params as $param) {
+ $result[$param['action_id']][$param['name']] = $param['value'];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get all action params for a given action
+ *
+ * @access public
+ * @param integer $action_id
+ * @return array
+ */
+ public function getAllByAction($action_id)
+ {
+ return $this->db->hashtable(self::TABLE)->eq('action_id', $action_id)->getAll('name', 'value');
+ }
+
+ /**
+ * Insert new parameters for an action
+ *
+ * @access public
+ * @param integer $action_id
+ * @param array $values
+ * @return boolean
+ */
+ public function create($action_id, array $values)
+ {
+ foreach ($values['params'] as $name => $value) {
+ $param = array(
+ 'action_id' => $action_id,
+ 'name' => $name,
+ 'value' => $value,
+ );
+
+ if (! $this->db->table(self::TABLE)->save($param)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Duplicate action parameters
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $action_id
+ * @param array $params
+ * @return boolean
+ */
+ public function duplicateParameters($project_id, $action_id, array $params)
+ {
+ foreach ($params as $name => $value) {
+ $value = $this->resolveParameter($project_id, $name, $value);
+
+ if ($value === false) {
+ $this->logger->error('ActionParameter::duplicateParameters => unable to resolve '.$name.'='.$value);
+ return false;
+ }
+
+ $values = array(
+ 'action_id' => $action_id,
+ 'name' => $name,
+ 'value' => $value,
+ );
+
+ if (! $this->db->table(self::TABLE)->insert($values)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Resolve action parameter values according to another project
+ *
+ * @access private
+ * @param integer $project_id
+ * @param string $name
+ * @param string $value
+ * @return mixed
+ */
+ private function resolveParameter($project_id, $name, $value)
+ {
+ switch ($name) {
+ case 'project_id':
+ return $value != $project_id ? $value : false;
+ case 'category_id':
+ return $this->category->getIdByName($project_id, $this->category->getNameById($value)) ?: false;
+ case 'src_column_id':
+ case 'dest_column_id':
+ case 'dst_column_id':
+ case 'column_id':
+ $column = $this->column->getById($value);
+ return empty($column) ? false : $this->column->getColumnIdByTitle($project_id, $column['title']) ?: false;
+ case 'user_id':
+ case 'owner_id':
+ return $this->projectPermission->isAssignable($project_id, $value) ? $value : false;
+ default:
+ return $value;
+ }
+ }
+}
diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php
deleted file mode 100644
index 580c1e14..00000000
--- a/app/Model/Authentication.php
+++ /dev/null
@@ -1,202 +0,0 @@
-<?php
-
-namespace Kanboard\Model;
-
-use Kanboard\Core\Request;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-use Gregwar\Captcha\CaptchaBuilder;
-
-/**
- * Authentication model
- *
- * @package model
- * @author Frederic Guillot
- */
-class Authentication extends Base
-{
- /**
- * Load automatically an authentication backend
- *
- * @access public
- * @param string $name Backend class name
- * @return mixed
- */
- public function backend($name)
- {
- if (! isset($this->container[$name])) {
- $class = '\Kanboard\Auth\\'.ucfirst($name);
- $this->container[$name] = new $class($this->container);
- }
-
- return $this->container[$name];
- }
-
- /**
- * Check if the current user is authenticated
- *
- * @access public
- * @return bool
- */
- public function isAuthenticated()
- {
- // If the user is already logged it's ok
- if ($this->userSession->isLogged()) {
-
- // Check if the user session match an existing user
- $userNotFound = ! $this->user->exists($this->userSession->getId());
- $reverseProxyWrongUser = REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->getUsername() !== $_SESSION['user']['username'];
-
- if ($userNotFound || $reverseProxyWrongUser) {
- $this->backend('rememberMe')->destroy($this->userSession->getId());
- $this->session->close();
- return false;
- }
-
- return true;
- }
-
- // We try first with the RememberMe cookie
- if (REMEMBER_ME_AUTH && $this->backend('rememberMe')->authenticate()) {
- return true;
- }
-
- // Then with the ReverseProxy authentication
- if (REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->authenticate()) {
- return true;
- }
-
- return false;
- }
-
- /**
- * Authenticate a user by different methods
- *
- * @access public
- * @param string $username Username
- * @param string $password Password
- * @return boolean
- */
- public function authenticate($username, $password)
- {
- if ($this->user->isLocked($username)) {
- $this->container['logger']->error('Account locked: '.$username);
- return false;
- } elseif ($this->backend('database')->authenticate($username, $password)) {
- $this->user->resetFailedLogin($username);
- return true;
- } elseif (LDAP_AUTH && $this->backend('ldap')->authenticate($username, $password)) {
- $this->user->resetFailedLogin($username);
- return true;
- }
-
- $this->handleFailedLogin($username);
- return false;
- }
-
- /**
- * Return true if the captcha must be shown
- *
- * @access public
- * @param string $username
- * @return boolean
- */
- public function hasCaptcha($username)
- {
- return $this->user->getFailedLogin($username) >= BRUTEFORCE_CAPTCHA;
- }
-
- /**
- * Handle failed login
- *
- * @access public
- * @param string $username
- */
- public function handleFailedLogin($username)
- {
- $this->user->incrementFailedLogin($username);
-
- if ($this->user->getFailedLogin($username) >= BRUTEFORCE_LOCKDOWN) {
- $this->container['logger']->critical('Locking account: '.$username);
- $this->user->lock($username, BRUTEFORCE_LOCKDOWN_DURATION);
- }
- }
-
- /**
- * Validate user login form
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateForm(array $values)
- {
- list($result, $errors) = $this->validateFormCredentials($values);
-
- if ($result) {
- if ($this->validateFormCaptcha($values) && $this->authenticate($values['username'], $values['password'])) {
- $this->createRememberMeSession($values);
- } else {
- $result = false;
- $errors['login'] = t('Bad username or password');
- }
- }
-
- return array($result, $errors);
- }
-
- /**
- * Validate credentials syntax
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateFormCredentials(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('username', t('The username is required')),
- new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
- new Validators\Required('password', t('The password is required')),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors(),
- );
- }
-
- /**
- * Validate captcha
- *
- * @access public
- * @param array $values Form values
- * @return boolean
- */
- public function validateFormCaptcha(array $values)
- {
- if ($this->hasCaptcha($values['username'])) {
- $builder = new CaptchaBuilder;
- $builder->setPhrase($this->session['captcha']);
- return $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : '');
- }
-
- return true;
- }
-
- /**
- * Create remember me session if necessary
- *
- * @access private
- * @param array $values Form values
- */
- private function createRememberMeSession(array $values)
- {
- if (REMEMBER_ME_AUTH && ! empty($values['remember_me'])) {
- $credentials = $this->backend('rememberMe')
- ->create($this->userSession->getId(), Request::getIpAddress(), Request::getUserAgent());
-
- $this->backend('rememberMe')->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']);
- }
- }
-}
diff --git a/app/Model/Board.php b/app/Model/Board.php
index 79a1a92d..c10be19f 100644
--- a/app/Model/Board.php
+++ b/app/Model/Board.php
@@ -2,10 +2,6 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-use PicoDb\Database;
-
/**
* Board model
*
@@ -15,13 +11,6 @@ use PicoDb\Database;
class Board extends Base
{
/**
- * SQL table name
- *
- * @var string
- */
- const TABLE = 'columns';
-
- /**
* Get Kanboard default columns
*
* @access public
@@ -75,7 +64,7 @@ class Board extends Base
'description' => $column['description'],
);
- if (! $this->db->table(self::TABLE)->save($values)) {
+ if (! $this->db->table(Column::TABLE)->save($values)) {
return false;
}
}
@@ -93,7 +82,7 @@ class Board extends Base
*/
public function duplicate($project_from, $project_to)
{
- $columns = $this->db->table(Board::TABLE)
+ $columns = $this->db->table(Column::TABLE)
->columns('title', 'task_limit', 'description')
->eq('project_id', $project_from)
->asc('position')
@@ -103,134 +92,6 @@ class Board extends Base
}
/**
- * Add a new column to the board
- *
- * @access public
- * @param integer $project_id Project id
- * @param string $title Column title
- * @param integer $task_limit Task limit
- * @param string $description Column description
- * @return boolean|integer
- */
- public function addColumn($project_id, $title, $task_limit = 0, $description = '')
- {
- $values = array(
- 'project_id' => $project_id,
- 'title' => $title,
- 'task_limit' => intval($task_limit),
- 'position' => $this->getLastColumnPosition($project_id) + 1,
- 'description' => $description,
- );
-
- return $this->persist(self::TABLE, $values);
- }
-
- /**
- * Update a column
- *
- * @access public
- * @param integer $column_id Column id
- * @param string $title Column title
- * @param integer $task_limit Task limit
- * @param string $description Optional description
- * @return boolean
- */
- public function updateColumn($column_id, $title, $task_limit = 0, $description = '')
- {
- return $this->db->table(self::TABLE)->eq('id', $column_id)->update(array(
- 'title' => $title,
- 'task_limit' => intval($task_limit),
- 'description' => $description,
- ));
- }
-
- /**
- * Get columns with consecutive positions
- *
- * If you remove a column, the positions are not anymore consecutives
- *
- * @access public
- * @param integer $project_id
- * @return array
- */
- public function getNormalizedColumnPositions($project_id)
- {
- $columns = $this->db->hashtable(self::TABLE)->eq('project_id', $project_id)->asc('position')->getAll('id', 'position');
- $position = 1;
-
- foreach ($columns as $column_id => $column_position) {
- $columns[$column_id] = $position++;
- }
-
- return $columns;
- }
-
- /**
- * Save the new positions for a set of columns
- *
- * @access public
- * @param array $columns Hashmap of column_id/column_position
- * @return boolean
- */
- public function saveColumnPositions(array $columns)
- {
- return $this->db->transaction(function (Database $db) use ($columns) {
-
- foreach ($columns as $column_id => $position) {
- if (! $db->table(Board::TABLE)->eq('id', $column_id)->update(array('position' => $position))) {
- return false;
- }
- }
- });
- }
-
- /**
- * Move a column down, increment the column position value
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $column_id Column id
- * @return boolean
- */
- public function moveDown($project_id, $column_id)
- {
- $columns = $this->getNormalizedColumnPositions($project_id);
- $positions = array_flip($columns);
-
- if (isset($columns[$column_id]) && $columns[$column_id] < count($columns)) {
- $position = ++$columns[$column_id];
- $columns[$positions[$position]]--;
-
- return $this->saveColumnPositions($columns);
- }
-
- return false;
- }
-
- /**
- * Move a column up, decrement the column position value
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $column_id Column id
- * @return boolean
- */
- public function moveUp($project_id, $column_id)
- {
- $columns = $this->getNormalizedColumnPositions($project_id);
- $positions = array_flip($columns);
-
- if (isset($columns[$column_id]) && $columns[$column_id] > 1) {
- $position = --$columns[$column_id];
- $columns[$positions[$position]]++;
-
- return $this->saveColumnPositions($columns);
- }
-
- return false;
- }
-
- /**
* Get all tasks sorted by columns and swimlanes
*
* @access public
@@ -241,7 +102,7 @@ class Board extends Base
public function getBoard($project_id, $callback = null)
{
$swimlanes = $this->swimlane->getSwimlanes($project_id);
- $columns = $this->getColumns($project_id);
+ $columns = $this->column->getAll($project_id);
$nb_columns = count($columns);
for ($i = 0, $ilen = count($swimlanes); $i < $ilen; $i++) {
@@ -309,174 +170,4 @@ class Board extends Base
return $prepend ? array(-1 => t('All columns')) + $listing : $listing;
}
-
- /**
- * Get the first column id for a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @return integer
- */
- public function getFirstColumn($project_id)
- {
- return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findOneColumn('id');
- }
-
- /**
- * Get the last column id for a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @return integer
- */
- public function getLastColumn($project_id)
- {
- return $this->db->table(self::TABLE)->eq('project_id', $project_id)->desc('position')->findOneColumn('id');
- }
-
- /**
- * Get the list of columns sorted by position [ column_id => title ]
- *
- * @access public
- * @param integer $project_id Project id
- * @param boolean $prepend Prepend a default value
- * @return array
- */
- public function getColumnsList($project_id, $prepend = false)
- {
- $listing = $this->db->hashtable(self::TABLE)->eq('project_id', $project_id)->asc('position')->getAll('id', 'title');
- return $prepend ? array(-1 => t('All columns')) + $listing : $listing;
- }
-
- /**
- * Get all columns sorted by position for a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @return array
- */
- public function getColumns($project_id)
- {
- return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findAll();
- }
-
- /**
- * Get the number of columns for a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @return integer
- */
- public function countColumns($project_id)
- {
- return $this->db->table(self::TABLE)->eq('project_id', $project_id)->count();
- }
-
- /**
- * Get a column by the id
- *
- * @access public
- * @param integer $column_id Column id
- * @return array
- */
- public function getColumn($column_id)
- {
- return $this->db->table(self::TABLE)->eq('id', $column_id)->findOne();
- }
-
- /**
- * Get a column id by the name
- *
- * @access public
- * @param integer $project_id
- * @param string $title
- * @return integer
- */
- public function getColumnIdByTitle($project_id, $title)
- {
- return (int) $this->db->table(self::TABLE)->eq('project_id', $project_id)->eq('title', $title)->findOneColumn('id');
- }
-
- /**
- * Get a column title by the id
- *
- * @access public
- * @param integer $column_id
- * @return integer
- */
- public function getColumnTitleById($column_id)
- {
- return $this->db->table(self::TABLE)->eq('id', $column_id)->findOneColumn('title');
- }
-
- /**
- * Get the position of the last column for a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @return integer
- */
- public function getLastColumnPosition($project_id)
- {
- return (int) $this->db
- ->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->desc('position')
- ->findOneColumn('position');
- }
-
- /**
- * Remove a column and all tasks associated to this column
- *
- * @access public
- * @param integer $column_id Column id
- * @return boolean
- */
- public function removeColumn($column_id)
- {
- return $this->db->table(self::TABLE)->eq('id', $column_id)->remove();
- }
-
- /**
- * Validate column modification
- *
- * @access public
- * @param array $values Required parameters to update a column
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Integer('task_limit', t('This value must be an integer')),
- new Validators\Required('title', t('The title is required')),
- new Validators\MaxLength('title', t('The maximum length is %d characters', 50), 50),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate column creation
- *
- * @access public
- * @param array $values Required parameters to save an action
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('project_id', t('The project id is required')),
- new Validators\Integer('project_id', t('This value must be an integer')),
- new Validators\Required('title', t('The title is required')),
- new Validators\MaxLength('title', t('The maximum length is %d characters', 50), 50),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/Category.php b/app/Model/Category.php
index bf40c60a..883fc282 100644
--- a/app/Model/Category.php
+++ b/app/Model/Category.php
@@ -2,9 +2,6 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-
/**
* Category model
*
@@ -196,11 +193,12 @@ class Category extends Base
*/
public function duplicate($src_project_id, $dst_project_id)
{
- $categories = $this->db->table(self::TABLE)
- ->columns('name')
- ->eq('project_id', $src_project_id)
- ->asc('name')
- ->findAll();
+ $categories = $this->db
+ ->table(self::TABLE)
+ ->columns('name')
+ ->eq('project_id', $src_project_id)
+ ->asc('name')
+ ->findAll();
foreach ($categories as $category) {
$category['project_id'] = $dst_project_id;
@@ -212,63 +210,4 @@ class Category extends Base
return true;
}
-
- /**
- * Validate category creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $rules = array(
- new Validators\Required('project_id', t('The project id is required')),
- new Validators\Required('name', t('The name is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate category modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The id is required')),
- new Validators\Required('name', t('The name is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Integer('id', t('The id must be an integer')),
- new Validators\Integer('project_id', t('The project id must be an integer')),
- new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50)
- );
- }
}
diff --git a/app/Model/Column.php b/app/Model/Column.php
new file mode 100644
index 00000000..ccdcb049
--- /dev/null
+++ b/app/Model/Column.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Column Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Column extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'columns';
+
+ /**
+ * Get a column by the id
+ *
+ * @access public
+ * @param integer $column_id Column id
+ * @return array
+ */
+ public function getById($column_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $column_id)->findOne();
+ }
+
+ /**
+ * Get the first column id for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return integer
+ */
+ public function getFirstColumnId($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findOneColumn('id');
+ }
+
+ /**
+ * Get the last column id for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return integer
+ */
+ public function getLastColumnId($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->desc('position')->findOneColumn('id');
+ }
+
+ /**
+ * Get the position of the last column for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return integer
+ */
+ public function getLastColumnPosition($project_id)
+ {
+ return (int) $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->desc('position')
+ ->findOneColumn('position');
+ }
+
+ /**
+ * Get a column id by the name
+ *
+ * @access public
+ * @param integer $project_id
+ * @param string $title
+ * @return integer
+ */
+ public function getColumnIdByTitle($project_id, $title)
+ {
+ return (int) $this->db->table(self::TABLE)->eq('project_id', $project_id)->eq('title', $title)->findOneColumn('id');
+ }
+
+ /**
+ * Get a column title by the id
+ *
+ * @access public
+ * @param integer $column_id
+ * @return integer
+ */
+ public function getColumnTitleById($column_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $column_id)->findOneColumn('title');
+ }
+
+ /**
+ * Get all columns sorted by position for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getAll($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findAll();
+ }
+
+ /**
+ * Get the list of columns sorted by position [ column_id => title ]
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param boolean $prepend Prepend a default value
+ * @return array
+ */
+ public function getList($project_id, $prepend = false)
+ {
+ $listing = $this->db->hashtable(self::TABLE)->eq('project_id', $project_id)->asc('position')->getAll('id', 'title');
+ return $prepend ? array(-1 => t('All columns')) + $listing : $listing;
+ }
+
+ /**
+ * Add a new column to the board
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param string $title Column title
+ * @param integer $task_limit Task limit
+ * @param string $description Column description
+ * @return boolean|integer
+ */
+ public function create($project_id, $title, $task_limit = 0, $description = '')
+ {
+ $values = array(
+ 'project_id' => $project_id,
+ 'title' => $title,
+ 'task_limit' => intval($task_limit),
+ 'position' => $this->getLastColumnPosition($project_id) + 1,
+ 'description' => $description,
+ );
+
+ return $this->persist(self::TABLE, $values);
+ }
+
+ /**
+ * Update a column
+ *
+ * @access public
+ * @param integer $column_id Column id
+ * @param string $title Column title
+ * @param integer $task_limit Task limit
+ * @param string $description Optional description
+ * @return boolean
+ */
+ public function update($column_id, $title, $task_limit = 0, $description = '')
+ {
+ return $this->db->table(self::TABLE)->eq('id', $column_id)->update(array(
+ 'title' => $title,
+ 'task_limit' => intval($task_limit),
+ 'description' => $description,
+ ));
+ }
+
+ /**
+ * Remove a column and all tasks associated to this column
+ *
+ * @access public
+ * @param integer $column_id Column id
+ * @return boolean
+ */
+ public function remove($column_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $column_id)->remove();
+ }
+
+ /**
+ * Change column position
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $column_id
+ * @param integer $position
+ * @return boolean
+ */
+ public function changePosition($project_id, $column_id, $position)
+ {
+ if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('project_id', $project_id)->count()) {
+ return false;
+ }
+
+ $column_ids = $this->db->table(self::TABLE)->eq('project_id', $project_id)->neq('id', $column_id)->asc('position')->findAllByColumn('id');
+ $offset = 1;
+ $results = array();
+
+ foreach ($column_ids as $current_column_id) {
+ if ($offset == $position) {
+ $offset++;
+ }
+
+ $results[] = $this->db->table(self::TABLE)->eq('id', $current_column_id)->update(array('position' => $offset));
+ $offset++;
+ }
+
+ $results[] = $this->db->table(self::TABLE)->eq('id', $column_id)->update(array('position' => $position));
+
+ return !in_array(false, $results, true);
+ }
+}
diff --git a/app/Model/Comment.php b/app/Model/Comment.php
index c7125a25..6eb4a1e5 100644
--- a/app/Model/Comment.php
+++ b/app/Model/Comment.php
@@ -3,8 +3,6 @@
namespace Kanboard\Model;
use Kanboard\Event\CommentEvent;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
/**
* Comment model
@@ -26,8 +24,9 @@ class Comment extends Base
*
* @var string
*/
- const EVENT_UPDATE = 'comment.update';
- const EVENT_CREATE = 'comment.create';
+ const EVENT_UPDATE = 'comment.update';
+ const EVENT_CREATE = 'comment.create';
+ const EVENT_USER_MENTION = 'comment.user.mention';
/**
* Get all comments for a given task
@@ -74,6 +73,7 @@ class Comment extends Base
self::TABLE.'.user_id',
self::TABLE.'.date_creation',
self::TABLE.'.comment',
+ self::TABLE.'.reference',
User::TABLE.'.username',
User::TABLE.'.name'
)
@@ -110,7 +110,9 @@ class Comment extends Base
$comment_id = $this->persist(self::TABLE, $values);
if ($comment_id) {
- $this->container['dispatcher']->dispatch(self::EVENT_CREATE, new CommentEvent(array('id' => $comment_id) + $values));
+ $event = new CommentEvent(array('id' => $comment_id) + $values);
+ $this->dispatcher->dispatch(self::EVENT_CREATE, $event);
+ $this->userMention->fireEvents($values['comment'], self::EVENT_USER_MENTION, $event);
}
return $comment_id;
@@ -148,62 +150,4 @@ class Comment extends Base
{
return $this->db->table(self::TABLE)->eq('id', $comment_id)->remove();
}
-
- /**
- * Validate comment creation
- *
- * @access public
- * @param array $values Required parameters to save an action
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $rules = array(
- new Validators\Required('task_id', t('This value is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate comment modification
- *
- * @access public
- * @param array $values Required parameters to save an action
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('This value is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Integer('id', t('This value must be an integer')),
- new Validators\Integer('task_id', t('This value must be an integer')),
- new Validators\Integer('user_id', t('This value must be an integer')),
- new Validators\Required('comment', t('Comment is required'))
- );
- }
}
diff --git a/app/Model/Config.php b/app/Model/Config.php
index cf634f80..6f009175 100644
--- a/app/Model/Config.php
+++ b/app/Model/Config.php
@@ -3,8 +3,7 @@
namespace Kanboard\Model;
use Kanboard\Core\Translator;
-use Kanboard\Core\Security;
-use Kanboard\Core\Session;
+use Kanboard\Core\Security\Token;
/**
* Config model
@@ -15,30 +14,6 @@ use Kanboard\Core\Session;
class Config extends Setting
{
/**
- * Get available currencies
- *
- * @access public
- * @return array
- */
- public function getCurrencies()
- {
- return array(
- 'USD' => t('USD - US Dollar'),
- 'EUR' => t('EUR - Euro'),
- 'GBP' => t('GBP - British Pound'),
- 'CHF' => t('CHF - Swiss Francs'),
- 'CAD' => t('CAD - Canadian Dollar'),
- 'AUD' => t('AUD - Australian Dollar'),
- 'NZD' => t('NZD - New Zealand Dollar'),
- 'INR' => t('INR - Indian Rupee'),
- 'JPY' => t('JPY - Japanese Yen'),
- 'RSD' => t('RSD - Serbian dinar'),
- 'SEK' => t('SEK - Swedish Krona'),
- 'NOK' => t('NOK - Norwegian Krone'),
- );
- }
-
- /**
* Get available timezones
*
* @access public
@@ -58,6 +33,31 @@ class Config extends Setting
}
/**
+ * Get current timezone
+ *
+ * @access public
+ * @return string
+ */
+ public function getCurrentTimezone()
+ {
+ if ($this->userSession->isLogged() && ! empty($this->sessionStorage->user['timezone'])) {
+ return $this->sessionStorage->user['timezone'];
+ }
+
+ return $this->get('application_timezone', 'UTC');
+ }
+
+ /**
+ * Set timezone
+ *
+ * @access public
+ */
+ public function setupTimezone()
+ {
+ date_default_timezone_set($this->getCurrentTimezone());
+ }
+
+ /**
* Get available languages
*
* @access public
@@ -69,14 +69,17 @@ class Config extends Setting
// Sorted by value
$languages = array(
'id_ID' => 'Bahasa Indonesia',
+ 'bs_BA' => 'Bosanski',
'cs_CZ' => 'Čeština',
'da_DK' => 'Dansk',
'de_DE' => 'Deutsch',
'en_US' => 'English',
'es_ES' => 'Español',
'fr_FR' => 'Français',
+ 'el_GR' => 'Grec',
'it_IT' => 'Italiano',
'hu_HU' => 'Magyar',
+ 'my_MY' => 'Melayu',
'nl_NL' => 'Nederlands',
'nb_NO' => 'Norsk',
'pl_PL' => 'Polski',
@@ -108,7 +111,7 @@ class Config extends Setting
public function getJsLanguageCode()
{
$languages = array(
- 'cs_CZ' => 'cz',
+ 'cs_CZ' => 'cs',
'da_DK' => 'da',
'de_DE' => 'de',
'en_US' => 'en',
@@ -129,7 +132,8 @@ class Config extends Setting
'zh_CN' => 'zh-cn',
'ja_JP' => 'ja',
'th_TH' => 'th',
- 'id_ID' => 'id'
+ 'id_ID' => 'id',
+ 'el_GR' => 'el',
);
$lang = $this->getCurrentLanguage();
@@ -145,51 +149,14 @@ class Config extends Setting
*/
public function getCurrentLanguage()
{
- if ($this->userSession->isLogged() && ! empty($this->session['user']['language'])) {
- return $this->session['user']['language'];
+ if ($this->userSession->isLogged() && ! empty($this->sessionStorage->user['language'])) {
+ return $this->sessionStorage->user['language'];
}
return $this->get('application_language', 'en_US');
}
/**
- * Get a config variable from the session or the database
- *
- * @access public
- * @param string $name Parameter name
- * @param string $default_value Default value of the parameter
- * @return string
- */
- public function get($name, $default_value = '')
- {
- if (! Session::isOpen()) {
- return $this->getOption($name, $default_value);
- }
-
- // Cache config in session
- if (! isset($this->session['config'][$name])) {
- $this->session['config'] = $this->getAll();
- }
-
- if (! empty($this->session['config'][$name])) {
- return $this->session['config'][$name];
- }
-
- return $default_value;
- }
-
- /**
- * Reload settings in the session and the translations
- *
- * @access public
- */
- public function reload()
- {
- $this->session['config'] = $this->getAll();
- $this->setupTranslations();
- }
-
- /**
* Load translations
*
* @access public
@@ -200,28 +167,27 @@ class Config extends Setting
}
/**
- * Get current timezone
+ * Get a config variable from the session or the database
*
* @access public
+ * @param string $name Parameter name
+ * @param string $default_value Default value of the parameter
* @return string
*/
- public function getCurrentTimezone()
+ public function get($name, $default_value = '')
{
- if ($this->userSession->isLogged() && ! empty($this->session['user']['timezone'])) {
- return $this->session['user']['timezone'];
- }
-
- return $this->get('application_timezone', 'UTC');
+ $options = $this->memoryCache->proxy($this, 'getAll');
+ return isset($options[$name]) && $options[$name] !== '' ? $options[$name] : $default_value;
}
/**
- * Set timezone
+ * Reload settings in the session and the translations
*
* @access public
*/
- public function setupTimezone()
+ public function reload()
{
- date_default_timezone_set($this->getCurrentTimezone());
+ $this->setupTranslations();
}
/**
@@ -232,7 +198,7 @@ class Config extends Setting
*/
public function optimizeDatabase()
{
- return $this->db->getconnection()->exec("VACUUM");
+ return $this->db->getconnection()->exec('VACUUM');
}
/**
@@ -262,10 +228,11 @@ class Config extends Setting
*
* @access public
* @param string $option Parameter name
+ * @return boolean
*/
public function regenerateToken($option)
{
- $this->save(array($option => Security::generateToken()));
+ return $this->save(array($option => Token::getToken()));
}
/**
diff --git a/app/Model/Currency.php b/app/Model/Currency.php
index c1156610..abcce2f0 100644
--- a/app/Model/Currency.php
+++ b/app/Model/Currency.php
@@ -2,9 +2,6 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-
/**
* Currency
*
@@ -21,6 +18,32 @@ class Currency extends Base
const TABLE = 'currencies';
/**
+ * Get available application currencies
+ *
+ * @access public
+ * @return array
+ */
+ public function getCurrencies()
+ {
+ return array(
+ 'USD' => t('USD - US Dollar'),
+ 'EUR' => t('EUR - Euro'),
+ 'GBP' => t('GBP - British Pound'),
+ 'CHF' => t('CHF - Swiss Francs'),
+ 'CAD' => t('CAD - Canadian Dollar'),
+ 'AUD' => t('AUD - Australian Dollar'),
+ 'NZD' => t('NZD - New Zealand Dollar'),
+ 'INR' => t('INR - Indian Rupee'),
+ 'JPY' => t('JPY - Japanese Yen'),
+ 'RSD' => t('RSD - Serbian dinar'),
+ 'SEK' => t('SEK - Swedish Krona'),
+ 'NOK' => t('NOK - Norwegian Krone'),
+ 'BAM' => t('BAM - Konvertible Mark'),
+ 'RUB' => t('RUB - Russian Ruble'),
+ );
+ }
+
+ /**
* Get all currency rates
*
* @access public
@@ -45,7 +68,7 @@ class Currency extends Base
$reference = $this->config->get('application_currency', 'USD');
if ($reference !== $currency) {
- $rates = $rates === null ? $this->db->hashtable(self::TABLE)->getAll('currency', 'rate') : array();
+ $rates = $rates === null ? $this->db->hashtable(self::TABLE)->getAll('currency', 'rate') : $rates;
$rate = isset($rates[$currency]) ? $rates[$currency] : 1;
return $rate * $price;
@@ -68,7 +91,7 @@ class Currency extends Base
return $this->update($currency, $rate);
}
- return $this->persist(self::TABLE, compact('currency', 'rate'));
+ return $this->db->table(self::TABLE)->insert(array('currency' => $currency, 'rate' => $rate));
}
/**
@@ -83,24 +106,4 @@ class Currency extends Base
{
return $this->db->table(self::TABLE)->eq('currency', $currency)->update(array('rate' => $rate));
}
-
- /**
- * Validate
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validate(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('currency', t('Field required')),
- new Validators\Required('rate', t('Field required')),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/CustomFilter.php b/app/Model/CustomFilter.php
index 6550b4a7..3a6a1a3a 100644
--- a/app/Model/CustomFilter.php
+++ b/app/Model/CustomFilter.php
@@ -2,9 +2,6 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-
/**
* Custom Filter model
*
@@ -102,63 +99,4 @@ class CustomFilter extends Base
{
return $this->db->table(self::TABLE)->eq('id', $filter_id)->remove();
}
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Required('project_id', t('Field required')),
- new Validators\Required('user_id', t('Field required')),
- new Validators\Required('name', t('Field required')),
- new Validators\Required('filter', t('Field required')),
- new Validators\Integer('user_id', t('This value must be an integer')),
- new Validators\Integer('project_id', t('This value must be an integer')),
- new Validators\MaxLength('name', t('The maximum length is %d characters', 100), 100),
- new Validators\MaxLength('filter', t('The maximum length is %d characters', 100), 100)
- );
- }
-
- /**
- * Validate filter creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $v = new Validator($values, $this->commonValidationRules());
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate filter modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('Field required')),
- new Validators\Integer('id', t('This value must be an integer')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/File.php b/app/Model/File.php
index daade517..03ea691d 100644
--- a/app/Model/File.php
+++ b/app/Model/File.php
@@ -2,31 +2,44 @@
namespace Kanboard\Model;
+use Exception;
use Kanboard\Event\FileEvent;
use Kanboard\Core\Tool;
use Kanboard\Core\ObjectStorage\ObjectStorageException;
/**
- * File model
+ * Base File Model
*
* @package model
* @author Frederic Guillot
*/
-class File extends Base
+abstract class File extends Base
{
/**
- * SQL table name
- *
- * @var string
- */
- const TABLE = 'files';
-
- /**
- * Events
+ * Get PicoDb query to get all files
*
- * @var string
+ * @access protected
+ * @return \PicoDb\Table
*/
- const EVENT_CREATE = 'file.create';
+ protected function getQuery()
+ {
+ return $this->db
+ ->table(static::TABLE)
+ ->columns(
+ static::TABLE.'.id',
+ static::TABLE.'.name',
+ static::TABLE.'.path',
+ static::TABLE.'.is_image',
+ static::TABLE.'.'.static::FOREIGN_KEY,
+ static::TABLE.'.date',
+ static::TABLE.'.user_id',
+ static::TABLE.'.size',
+ User::TABLE.'.username',
+ User::TABLE.'.name as user_name'
+ )
+ ->join(User::TABLE, 'id', 'user_id')
+ ->asc(static::TABLE.'.name');
+ }
/**
* Get a file by the id
@@ -37,146 +50,120 @@ class File extends Base
*/
public function getById($file_id)
{
- return $this->db->table(self::TABLE)->eq('id', $file_id)->findOne();
+ return $this->db->table(static::TABLE)->eq('id', $file_id)->findOne();
}
/**
- * Remove a file
+ * Get all files
*
* @access public
- * @param integer $file_id File id
- * @return bool
+ * @param integer $id
+ * @return array
*/
- public function remove($file_id)
+ public function getAll($id)
{
- try {
- $file = $this->getbyId($file_id);
- $this->objectStorage->remove($file['path']);
-
- if ($file['is_image'] == 1) {
- $this->objectStorage->remove($this->getThumbnailPath($file['path']));
- }
-
- return $this->db->table(self::TABLE)->eq('id', $file['id'])->remove();
- } catch (ObjectStorageException $e) {
- $this->logger->error($e->getMessage());
- return false;
- }
+ return $this->getQuery()->eq(static::FOREIGN_KEY, $id)->findAll();
}
/**
- * Remove all files for a given task
+ * Get all images
*
* @access public
- * @param integer $task_id Task id
- * @return bool
+ * @param integer $id
+ * @return array
*/
- public function removeAll($task_id)
+ public function getAllImages($id)
{
- $file_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->asc('id')->findAllByColumn('id');
- $results = array();
-
- foreach ($file_ids as $file_id) {
- $results[] = $this->remove($file_id);
- }
+ return $this->getQuery()->eq(static::FOREIGN_KEY, $id)->eq('is_image', 1)->findAll();
+ }
- return ! in_array(false, $results, true);
+ /**
+ * Get all files without images
+ *
+ * @access public
+ * @param integer $id
+ * @return array
+ */
+ public function getAllDocuments($id)
+ {
+ return $this->getQuery()->eq(static::FOREIGN_KEY, $id)->eq('is_image', 0)->findAll();
}
/**
* Create a file entry in the database
*
* @access public
- * @param integer $task_id Task id
+ * @param integer $id Foreign key
* @param string $name Filename
* @param string $path Path on the disk
* @param integer $size File size
* @return bool|integer
*/
- public function create($task_id, $name, $path, $size)
+ public function create($id, $name, $path, $size)
{
- $result = $this->db->table(self::TABLE)->save(array(
- 'task_id' => $task_id,
+ $values = array(
+ static::FOREIGN_KEY => $id,
'name' => substr($name, 0, 255),
'path' => $path,
'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))
- );
+ $result = $this->db->table(static::TABLE)->insert($values);
- return (int) $this->db->getLastId();
+ if ($result) {
+ $file_id = (int) $this->db->getLastId();
+ $event = new FileEvent($values + array('file_id' => $file_id));
+ $this->dispatcher->dispatch(static::EVENT_CREATE, $event);
+ return $file_id;
}
return false;
}
/**
- * Get PicoDb query to get all files
+ * Remove all files
*
* @access public
- * @return \PicoDb\Table
+ * @param integer $id
+ * @return bool
*/
- public function getQuery()
+ public function removeAll($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')
- ->asc(self::TABLE.'.name');
- }
+ $file_ids = $this->db->table(static::TABLE)->eq(static::FOREIGN_KEY, $id)->asc('id')->findAllByColumn('id');
+ $results = array();
- /**
- * 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();
- }
+ foreach ($file_ids as $file_id) {
+ $results[] = $this->remove($file_id);
+ }
- /**
- * Get all images for a given task
- *
- * @access public
- * @param integer $task_id Task id
- * @return array
- */
- public function getAllImages($task_id)
- {
- return $this->getQuery()->eq('task_id', $task_id)->eq('is_image', 1)->findAll();
+ return ! in_array(false, $results, true);
}
/**
- * Get all files without images for a given task
+ * Remove a file
*
* @access public
- * @param integer $task_id Task id
- * @return array
+ * @param integer $file_id File id
+ * @return bool
*/
- public function getAllDocuments($task_id)
+ public function remove($file_id)
{
- return $this->getQuery()->eq('task_id', $task_id)->eq('is_image', 0)->findAll();
+ try {
+ $file = $this->getById($file_id);
+ $this->objectStorage->remove($file['path']);
+
+ if ($file['is_image'] == 1) {
+ $this->objectStorage->remove($this->getThumbnailPath($file['path']));
+ }
+
+ return $this->db->table(static::TABLE)->eq('id', $file['id'])->remove();
+ } catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ return false;
+ }
}
/**
@@ -202,125 +189,96 @@ class File extends Base
}
/**
- * Return the image mimetype based on the file extension
+ * Generate the path for a thumbnails
*
* @access public
- * @param $filename
+ * @param string $key Storage key
* @return string
*/
- public function getImageMimeType($filename)
+ public function getThumbnailPath($key)
{
- $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
-
- switch ($extension) {
- case 'jpeg':
- case 'jpg':
- return 'image/jpeg';
- case 'png':
- return 'image/png';
- case 'gif':
- return 'image/gif';
- default:
- return 'image/jpeg';
- }
+ return 'thumbnails'.DIRECTORY_SEPARATOR.$key;
}
/**
* Generate the path for a new filename
*
* @access public
- * @param integer $project_id Project id
- * @param integer $task_id Task id
+ * @param integer $id Foreign key
* @param string $filename Filename
* @return string
*/
- public function generatePath($project_id, $task_id, $filename)
- {
- return $project_id.DIRECTORY_SEPARATOR.$task_id.DIRECTORY_SEPARATOR.hash('sha1', $filename.time());
- }
-
- /**
- * Generate the path for a thumbnails
- *
- * @access public
- * @param string $key Storage key
- * @return string
- */
- public function getThumbnailPath($key)
+ public function generatePath($id, $filename)
{
- return 'thumbnails'.DIRECTORY_SEPARATOR.$key;
+ return static::PATH_PREFIX.DIRECTORY_SEPARATOR.$id.DIRECTORY_SEPARATOR.hash('sha1', $filename.time());
}
/**
- * Handle file upload
+ * Upload multiple files
*
* @access public
- * @param integer $project_id Project id
- * @param integer $task_id Task id
- * @param string $form_name File form name
+ * @param integer $id
+ * @param array $files
* @return bool
*/
- public function uploadFiles($project_id, $task_id, $form_name)
+ public function uploadFiles($id, array $files)
{
try {
- if (empty($_FILES[$form_name])) {
+ if (empty($files)) {
return false;
}
- foreach ($_FILES[$form_name]['error'] as $key => $error) {
- if ($error == UPLOAD_ERR_OK && $_FILES[$form_name]['size'][$key] > 0) {
- $original_filename = $_FILES[$form_name]['name'][$key];
- $uploaded_filename = $_FILES[$form_name]['tmp_name'][$key];
- $destination_filename = $this->generatePath($project_id, $task_id, $original_filename);
-
- if ($this->isImage($original_filename)) {
- $this->generateThumbnailFromFile($uploaded_filename, $destination_filename);
- }
-
- $this->objectStorage->moveUploadedFile($uploaded_filename, $destination_filename);
-
- $this->create(
- $task_id,
- $original_filename,
- $destination_filename,
- $_FILES[$form_name]['size'][$key]
- );
- }
+ foreach (array_keys($files['error']) as $key) {
+ $file = array(
+ 'name' => $files['name'][$key],
+ 'tmp_name' => $files['tmp_name'][$key],
+ 'size' => $files['size'][$key],
+ 'error' => $files['error'][$key],
+ );
+
+ $this->uploadFile($id, $file);
}
return true;
- } catch (ObjectStorageException $e) {
+ } catch (Exception $e) {
$this->logger->error($e->getMessage());
return false;
}
}
/**
- * Handle screenshot upload
+ * Upload a file
*
* @access public
- * @param integer $project_id Project id
- * @param integer $task_id Task id
- * @param string $blob Base64 encoded image
- * @return bool|integer
+ * @param integer $id
+ * @param array $file
*/
- public function uploadScreenshot($project_id, $task_id, $blob)
+ public function uploadFile($id, array $file)
{
- $original_filename = e('Screenshot taken %s', dt('%B %e, %Y at %k:%M %p', time())).'.png';
- return $this->uploadContent($project_id, $task_id, $original_filename, $blob);
+ if ($file['error'] == UPLOAD_ERR_OK && $file['size'] > 0) {
+ $destination_filename = $this->generatePath($id, $file['name']);
+
+ if ($this->isImage($file['name'])) {
+ $this->generateThumbnailFromFile($file['tmp_name'], $destination_filename);
+ }
+
+ $this->objectStorage->moveUploadedFile($file['tmp_name'], $destination_filename);
+ $this->create($id, $file['name'], $destination_filename, $file['size']);
+ } else {
+ throw new Exception('File not uploaded: '.var_export($file['error'], true));
+ }
}
/**
* Handle file upload (base64 encoded content)
*
* @access public
- * @param integer $project_id Project id
- * @param integer $task_id Task id
- * @param string $original_filename Filename
- * @param string $blob Base64 encoded file
+ * @param integer $id
+ * @param string $original_filename
+ * @param string $blob
* @return bool|integer
*/
- public function uploadContent($project_id, $task_id, $original_filename, $blob)
+ public function uploadContent($id, $original_filename, $blob)
{
try {
$data = base64_decode($blob);
@@ -329,7 +287,7 @@ class File extends Base
return false;
}
- $destination_filename = $this->generatePath($project_id, $task_id, $original_filename);
+ $destination_filename = $this->generatePath($id, $original_filename);
$this->objectStorage->put($destination_filename, $data);
if ($this->isImage($original_filename)) {
@@ -337,7 +295,7 @@ class File extends Base
}
return $this->create(
- $task_id,
+ $id,
$original_filename,
$destination_filename,
strlen($data)
diff --git a/app/Model/Group.php b/app/Model/Group.php
new file mode 100644
index 00000000..67899503
--- /dev/null
+++ b/app/Model/Group.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Group Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Group extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'groups';
+
+ /**
+ * Get query to fetch all groups
+ *
+ * @access public
+ * @return \PicoDb\Table
+ */
+ public function getQuery()
+ {
+ return $this->db->table(self::TABLE);
+ }
+
+ /**
+ * Get a specific group by id
+ *
+ * @access public
+ * @param integer $group_id
+ * @return array
+ */
+ public function getById($group_id)
+ {
+ return $this->getQuery()->eq('id', $group_id)->findOne();
+ }
+
+ /**
+ * Get a specific group by external id
+ *
+ * @access public
+ * @param integer $external_id
+ * @return array
+ */
+ public function getByExternalId($external_id)
+ {
+ return $this->getQuery()->eq('external_id', $external_id)->findOne();
+ }
+
+ /**
+ * Get all groups
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ return $this->getQuery()->asc('name')->findAll();
+ }
+
+ /**
+ * Search groups by name
+ *
+ * @access public
+ * @param string $input
+ * @return array
+ */
+ public function search($input)
+ {
+ return $this->db->table(self::TABLE)->ilike('name', '%'.$input.'%')->asc('name')->findAll();
+ }
+
+ /**
+ * Remove a group
+ *
+ * @access public
+ * @param integer $group_id
+ * @return array
+ */
+ public function remove($group_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $group_id)->remove();
+ }
+
+ /**
+ * Create a new group
+ *
+ * @access public
+ * @param string $name
+ * @param string $external_id
+ * @return integer|boolean
+ */
+ public function create($name, $external_id = '')
+ {
+ return $this->persist(self::TABLE, array(
+ 'name' => $name,
+ 'external_id' => $external_id,
+ ));
+ }
+
+ /**
+ * Update existing group
+ *
+ * @access public
+ * @param array $values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values);
+ }
+}
diff --git a/app/Model/GroupMember.php b/app/Model/GroupMember.php
new file mode 100644
index 00000000..7ed5f733
--- /dev/null
+++ b/app/Model/GroupMember.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Group Member Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class GroupMember extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'group_has_users';
+
+ /**
+ * Get query to fetch all users
+ *
+ * @access public
+ * @param integer $group_id
+ * @return \PicoDb\Table
+ */
+ public function getQuery($group_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->join(User::TABLE, 'id', 'user_id')
+ ->eq('group_id', $group_id);
+ }
+
+ /**
+ * Get all users
+ *
+ * @access public
+ * @param integer $group_id
+ * @return array
+ */
+ public function getMembers($group_id)
+ {
+ return $this->getQuery($group_id)->findAll();
+ }
+
+ /**
+ * Get all not members
+ *
+ * @access public
+ * @param integer $group_id
+ * @return array
+ */
+ public function getNotMembers($group_id)
+ {
+ $subquery = $this->db->table(self::TABLE)
+ ->columns('user_id')
+ ->eq('group_id', $group_id);
+
+ return $this->db->table(User::TABLE)
+ ->notInSubquery('id', $subquery)
+ ->findAll();
+ }
+
+ /**
+ * Add user to a group
+ *
+ * @access public
+ * @param integer $group_id
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function addUser($group_id, $user_id)
+ {
+ return $this->db->table(self::TABLE)->insert(array(
+ 'group_id' => $group_id,
+ 'user_id' => $user_id,
+ ));
+ }
+
+ /**
+ * Remove user from a group
+ *
+ * @access public
+ * @param integer $group_id
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function removeUser($group_id, $user_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->eq('group_id', $group_id)
+ ->eq('user_id', $user_id)
+ ->remove();
+ }
+
+ /**
+ * Check if a user is member
+ *
+ * @access public
+ * @param integer $group_id
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function isMember($group_id, $user_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->eq('group_id', $group_id)
+ ->eq('user_id', $user_id)
+ ->exists();
+ }
+}
diff --git a/app/Model/LastLogin.php b/app/Model/LastLogin.php
index 0f148ead..feb5f5a3 100644
--- a/app/Model/LastLogin.php
+++ b/app/Model/LastLogin.php
@@ -36,29 +36,39 @@ class LastLogin extends Base
*/
public function create($auth_type, $user_id, $ip, $user_agent)
{
- // Cleanup old sessions if necessary
+ $this->cleanup($user_id);
+
+ return $this->db
+ ->table(self::TABLE)
+ ->insert(array(
+ 'auth_type' => $auth_type,
+ 'user_id' => $user_id,
+ 'ip' => $ip,
+ 'user_agent' => substr($user_agent, 0, 255),
+ 'date_creation' => time(),
+ ));
+ }
+
+ /**
+ * Cleanup login history
+ *
+ * @access public
+ * @param integer $user_id
+ */
+ public function cleanup($user_id)
+ {
$connections = $this->db
->table(self::TABLE)
->eq('user_id', $user_id)
- ->desc('date_creation')
+ ->desc('id')
->findAllByColumn('id');
if (count($connections) >= self::NB_LOGINS) {
$this->db->table(self::TABLE)
- ->eq('user_id', $user_id)
- ->notin('id', array_slice($connections, 0, self::NB_LOGINS - 1))
- ->remove();
+ ->eq('user_id', $user_id)
+ ->notin('id', array_slice($connections, 0, self::NB_LOGINS - 1))
+ ->remove();
}
-
- return $this->db
- ->table(self::TABLE)
- ->insert(array(
- 'auth_type' => $auth_type,
- 'user_id' => $user_id,
- 'ip' => $ip,
- 'user_agent' => $user_agent,
- 'date_creation' => time(),
- ));
}
/**
@@ -73,7 +83,7 @@ class LastLogin extends Base
return $this->db
->table(self::TABLE)
->eq('user_id', $user_id)
- ->desc('date_creation')
+ ->desc('id')
->columns('id', 'auth_type', 'ip', 'user_agent', 'date_creation')
->findAll();
}
diff --git a/app/Model/Link.php b/app/Model/Link.php
index 00b6dfc5..7b81a237 100644
--- a/app/Model/Link.php
+++ b/app/Model/Link.php
@@ -3,8 +3,6 @@
namespace Kanboard\Model;
use PDO;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
/**
* Link model
@@ -176,47 +174,4 @@ class Link extends Base
$this->db->table(self::TABLE)->eq('opposite_id', $link_id)->update(array('opposite_id' => 0));
return $this->db->table(self::TABLE)->eq('id', $link_id)->remove();
}
-
- /**
- * Validate creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('label', t('Field required')),
- new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), self::TABLE),
- new Validators\NotEquals('label', 'opposite_label', t('The labels must be different')),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('id', t('Field required')),
- new Validators\Required('opposite_id', t('Field required')),
- new Validators\Required('label', t('Field required')),
- new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), self::TABLE),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/Metadata.php b/app/Model/Metadata.php
index 83c8f499..690b2265 100644
--- a/app/Model/Metadata.php
+++ b/app/Model/Metadata.php
@@ -95,4 +95,17 @@ abstract class Metadata extends Base
return ! in_array(false, $results, true);
}
+
+ /**
+ * Remove a metadata
+ *
+ * @access public
+ * @param integer $entity_id
+ * @param string $name
+ * @return bool
+ */
+ public function remove($entity_id, $name)
+ {
+ return $this->db->table(static::TABLE)->eq($this->getEntityKey(), $entity_id)->eq('name', $name)->remove();
+ }
}
diff --git a/app/Model/Notification.php b/app/Model/Notification.php
index f1122993..c252aa31 100644
--- a/app/Model/Notification.php
+++ b/app/Model/Notification.php
@@ -72,8 +72,12 @@ class Notification extends Base
return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']);
case Comment::EVENT_CREATE:
return e('%s commented on the task #%d', $event_author, $event_data['task']['id']);
- case File::EVENT_CREATE:
+ case TaskFile::EVENT_CREATE:
return e('%s attached a file to the task #%d', $event_author, $event_data['task']['id']);
+ case Task::EVENT_USER_MENTION:
+ return e('%s mentioned you in the task #%d', $event_author, $event_data['task']['id']);
+ case Comment::EVENT_USER_MENTION:
+ return e('%s mentioned you in a comment on the task #%d', $event_author, $event_data['task']['id']);
default:
return e('Notification');
}
@@ -90,53 +94,41 @@ class Notification extends Base
public function getTitleWithoutAuthor($event_name, array $event_data)
{
switch ($event_name) {
- case File::EVENT_CREATE:
- $title = e('New attachment on task #%d: %s', $event_data['file']['task_id'], $event_data['file']['name']);
- break;
+ case TaskFile::EVENT_CREATE:
+ return e('New attachment on task #%d: %s', $event_data['file']['task_id'], $event_data['file']['name']);
case Comment::EVENT_CREATE:
- $title = e('New comment on task #%d', $event_data['comment']['task_id']);
- break;
+ return e('New comment on task #%d', $event_data['comment']['task_id']);
case Comment::EVENT_UPDATE:
- $title = e('Comment updated on task #%d', $event_data['comment']['task_id']);
- break;
+ return e('Comment updated on task #%d', $event_data['comment']['task_id']);
case Subtask::EVENT_CREATE:
- $title = e('New subtask on task #%d', $event_data['subtask']['task_id']);
- break;
+ return e('New subtask on task #%d', $event_data['subtask']['task_id']);
case Subtask::EVENT_UPDATE:
- $title = e('Subtask updated on task #%d', $event_data['subtask']['task_id']);
- break;
+ return e('Subtask updated on task #%d', $event_data['subtask']['task_id']);
case Task::EVENT_CREATE:
- $title = e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']);
- break;
+ return e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']);
case Task::EVENT_UPDATE:
- $title = e('Task updated #%d', $event_data['task']['id']);
- break;
+ return e('Task updated #%d', $event_data['task']['id']);
case Task::EVENT_CLOSE:
- $title = e('Task #%d closed', $event_data['task']['id']);
- break;
+ return e('Task #%d closed', $event_data['task']['id']);
case Task::EVENT_OPEN:
- $title = e('Task #%d opened', $event_data['task']['id']);
- break;
+ return e('Task #%d opened', $event_data['task']['id']);
case Task::EVENT_MOVE_COLUMN:
- $title = e('Column changed for task #%d', $event_data['task']['id']);
- break;
+ return e('Column changed for task #%d', $event_data['task']['id']);
case Task::EVENT_MOVE_POSITION:
- $title = e('New position for task #%d', $event_data['task']['id']);
- break;
+ return e('New position for task #%d', $event_data['task']['id']);
case Task::EVENT_MOVE_SWIMLANE:
- $title = e('Swimlane changed for task #%d', $event_data['task']['id']);
- break;
+ return e('Swimlane changed for task #%d', $event_data['task']['id']);
case Task::EVENT_ASSIGNEE_CHANGE:
- $title = e('Assignee changed on task #%d', $event_data['task']['id']);
- break;
+ return e('Assignee changed on task #%d', $event_data['task']['id']);
case Task::EVENT_OVERDUE:
$nb = count($event_data['tasks']);
- $title = $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $event_data['tasks'][0]['id']);
- break;
+ return $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $event_data['tasks'][0]['id']);
+ case Task::EVENT_USER_MENTION:
+ return e('You were mentioned in the task #%d', $event_data['task']['id']);
+ case Comment::EVENT_USER_MENTION:
+ return e('You were mentioned in a comment on the task #%d', $event_data['task']['id']);
default:
- $title = e('Notification');
+ return e('Notification');
}
-
- return $title;
}
}
diff --git a/app/Model/PasswordReset.php b/app/Model/PasswordReset.php
new file mode 100644
index 00000000..c2d7dde9
--- /dev/null
+++ b/app/Model/PasswordReset.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Password Reset Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class PasswordReset extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'password_reset';
+
+ /**
+ * Token duration (30 minutes)
+ *
+ * @var string
+ */
+ const DURATION = 1800;
+
+ /**
+ * Get all tokens
+ *
+ * @access public
+ * @param integer $user_id
+ * @return array
+ */
+ public function getAll($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('user_id', $user_id)->desc('date_creation')->limit(100)->findAll();
+ }
+
+ /**
+ * Generate a new reset token for a user
+ *
+ * @access public
+ * @param string $username
+ * @param integer $expiration
+ * @return boolean|string
+ */
+ public function create($username, $expiration = 0)
+ {
+ $user_id = $this->db->table(User::TABLE)->eq('username', $username)->neq('email', '')->notNull('email')->findOneColumn('id');
+
+ if (! $user_id) {
+ return false;
+ }
+
+ $token = $this->token->getToken();
+
+ $result = $this->db->table(self::TABLE)->insert(array(
+ 'token' => $token,
+ 'user_id' => $user_id,
+ 'date_expiration' => $expiration ?: time() + self::DURATION,
+ 'date_creation' => time(),
+ 'ip' => $this->request->getIpAddress(),
+ 'user_agent' => $this->request->getUserAgent(),
+ 'is_active' => 1,
+ ));
+
+ return $result ? $token : false;
+ }
+
+ /**
+ * Get user id from the token
+ *
+ * @access public
+ * @param string $token
+ * @return integer
+ */
+ public function getUserIdByToken($token)
+ {
+ return $this->db->table(self::TABLE)->eq('token', $token)->eq('is_active', 1)->gte('date_expiration', time())->findOneColumn('user_id');
+ }
+
+ /**
+ * Disable all tokens for a user
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function disable($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('user_id', $user_id)->update(array('is_active' => 0));
+ }
+}
diff --git a/app/Model/Project.php b/app/Model/Project.php
index b767af26..a79e46a1 100644
--- a/app/Model/Project.php
+++ b/app/Model/Project.php
@@ -2,9 +2,8 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-use Kanboard\Core\Security;
+use Kanboard\Core\Security\Token;
+use Kanboard\Core\Security\Role;
/**
* Project model
@@ -48,6 +47,22 @@ class Project extends Base
}
/**
+ * Get a project by id with owner name
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getByIdWithOwner($project_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->columns(self::TABLE.'.*', User::TABLE.'.username AS owner_username', User::TABLE.'.name AS owner_name')
+ ->eq(self::TABLE.'.id', $project_id)
+ ->join(User::TABLE, 'id', 'owner_id')
+ ->findOne();
+ }
+
+ /**
* Get a project by the name
*
* @access public
@@ -226,7 +241,7 @@ class Project extends Base
{
$stats = array();
$stats['nb_active_tasks'] = 0;
- $columns = $this->board->getColumns($project_id);
+ $columns = $this->column->getAll($project_id);
$column_stats = $this->board->getColumnStats($project_id);
foreach ($columns as &$column) {
@@ -250,7 +265,7 @@ class Project extends Base
*/
public function getColumnStats(array &$project)
{
- $project['columns'] = $this->board->getColumns($project['id']);
+ $project['columns'] = $this->column->getAll($project['id']);
$stats = $this->board->getColumnStats($project['id']);
foreach ($project['columns'] as &$column) {
@@ -277,23 +292,6 @@ class Project extends Base
}
/**
- * Fetch more information for each project
- *
- * @access public
- * @param array $projects
- * @return array
- */
- public function applyProjectDetails(array $projects)
- {
- foreach ($projects as &$project) {
- $this->getColumnStats($project);
- $project = array_merge($project, $this->projectPermission->getProjectUsers($project['id']));
- }
-
- return $projects;
- }
-
- /**
* Get project summary for a list of project
*
* @access public
@@ -308,30 +306,13 @@ class Project extends Base
return $this->db
->table(Project::TABLE)
- ->in('id', $project_ids)
+ ->columns(self::TABLE.'.*', User::TABLE.'.username AS owner_username', User::TABLE.'.name AS owner_name')
+ ->join(User::TABLE, 'id', 'owner_id')
+ ->in(self::TABLE.'.id', $project_ids)
->callback(array($this, 'applyColumnStats'));
}
/**
- * Get project details (users + columns) for a list of project
- *
- * @access public
- * @param array $project_ids List of project id
- * @return \PicoDb\Table
- */
- public function getQueryProjectDetails(array $project_ids)
- {
- if (empty($project_ids)) {
- return $this->db->table(Project::TABLE)->limit(0);
- }
-
- return $this->db
- ->table(Project::TABLE)
- ->in('id', $project_ids)
- ->callback(array($this, 'applyProjectDetails'));
- }
-
- /**
* Create a project
*
* @access public
@@ -347,11 +328,14 @@ class Project extends Base
$values['token'] = '';
$values['last_modified'] = time();
$values['is_private'] = empty($values['is_private']) ? 0 : 1;
+ $values['owner_id'] = $user_id;
if (! empty($values['identifier'])) {
$values['identifier'] = strtoupper($values['identifier']);
}
+ $this->convertIntegerFields($values, array('priority_default', 'priority_start', 'priority_end'));
+
if (! $this->db->table(self::TABLE)->save($values)) {
$this->db->cancelTransaction();
return false;
@@ -365,7 +349,7 @@ class Project extends Base
}
if ($add_user && $user_id) {
- $this->projectPermission->addManager($project_id, $user_id);
+ $this->projectUserRole->addUser($project_id, $user_id, Role::PROJECT_MANAGER);
}
$this->category->createDefaultCategories($project_id);
@@ -418,6 +402,8 @@ class Project extends Base
$values['identifier'] = strtoupper($values['identifier']);
}
+ $this->convertIntegerFields($values, array('priority_default', 'priority_start', 'priority_end'));
+
return $this->exists($values['id']) &&
$this->db->table(self::TABLE)->eq('id', $values['id'])->save($values);
}
@@ -491,7 +477,7 @@ class Project extends Base
$this->db
->table(self::TABLE)
->eq('id', $project_id)
- ->save(array('is_public' => 1, 'token' => Security::generateToken()));
+ ->save(array('is_public' => 1, 'token' => Token::getToken()));
}
/**
@@ -509,72 +495,4 @@ class Project extends Base
->eq('id', $project_id)
->save(array('is_public' => 0, 'token' => ''));
}
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Integer('id', t('This value must be an integer')),
- new Validators\Integer('is_active', t('This value must be an integer')),
- new Validators\Required('name', t('The project name is required')),
- new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50),
- new Validators\MaxLength('identifier', t('The maximum length is %d characters', 50), 50),
- new Validators\MaxLength('start_date', t('The maximum length is %d characters', 10), 10),
- new Validators\MaxLength('end_date', t('The maximum length is %d characters', 10), 10),
- new Validators\AlphaNumeric('identifier', t('This value must be alphanumeric')) ,
- new Validators\Unique('name', t('This project must be unique'), $this->db->getConnection(), self::TABLE),
- new Validators\Unique('identifier', t('The identifier must be unique'), $this->db->getConnection(), self::TABLE),
- );
- }
-
- /**
- * Validate project creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- if (! empty($values['identifier'])) {
- $values['identifier'] = strtoupper($values['identifier']);
- }
-
- $v = new Validator($values, $this->commonValidationRules());
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate project modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- if (! empty($values['identifier'])) {
- $values['identifier'] = strtoupper($values['identifier']);
- }
-
- $rules = array(
- new Validators\Required('id', t('This value is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/ProjectActivity.php b/app/Model/ProjectActivity.php
index 309bab9a..74df26a1 100644
--- a/app/Model/ProjectActivity.php
+++ b/app/Model/ProjectActivity.php
@@ -168,15 +168,11 @@ class ProjectActivity extends Base
*/
public function cleanup($max)
{
- if ($this->db->table(self::TABLE)->count() > $max) {
- $this->db->execute('
- DELETE FROM '.self::TABLE.'
- WHERE id <= (
- SELECT id FROM (
- SELECT id FROM '.self::TABLE.' ORDER BY id DESC LIMIT 1 OFFSET '.$max.'
- ) foo
- )'
- );
+ $total = $this->db->table(self::TABLE)->count();
+
+ if ($total > $max) {
+ $ids = $this->db->table(self::TABLE)->asc('id')->limit($total - $max)->findAllByColumn('id');
+ $this->db->table(self::TABLE)->in('id', $ids)->remove();
}
}
diff --git a/app/Model/ProjectAnalytic.php b/app/Model/ProjectAnalytic.php
deleted file mode 100644
index 92364c0c..00000000
--- a/app/Model/ProjectAnalytic.php
+++ /dev/null
@@ -1,182 +0,0 @@
-<?php
-
-namespace Kanboard\Model;
-
-/**
- * Project analytic model
- *
- * @package model
- * @author Frederic Guillot
- */
-class ProjectAnalytic extends Base
-{
- /**
- * Get tasks repartition
- *
- * @access public
- * @param integer $project_id Project id
- * @return array
- */
- public function getTaskRepartition($project_id)
- {
- $metrics = array();
- $total = 0;
- $columns = $this->board->getColumns($project_id);
-
- foreach ($columns as $column) {
- $nb_tasks = $this->taskFinder->countByColumnId($project_id, $column['id']);
- $total += $nb_tasks;
-
- $metrics[] = array(
- 'column_title' => $column['title'],
- 'nb_tasks' => $nb_tasks,
- );
- }
-
- if ($total === 0) {
- return array();
- }
-
- foreach ($metrics as &$metric) {
- $metric['percentage'] = round(($metric['nb_tasks'] * 100) / $total, 2);
- }
-
- return $metrics;
- }
-
- /**
- * Get users repartition
- *
- * @access public
- * @param integer $project_id
- * @return array
- */
- public function getUserRepartition($project_id)
- {
- $metrics = array();
- $total = 0;
- $tasks = $this->taskFinder->getAll($project_id);
- $users = $this->projectPermission->getMemberList($project_id);
-
- foreach ($tasks as $task) {
- $user = isset($users[$task['owner_id']]) ? $users[$task['owner_id']] : $users[0];
- $total++;
-
- if (! isset($metrics[$user])) {
- $metrics[$user] = array(
- 'nb_tasks' => 0,
- 'percentage' => 0,
- 'user' => $user,
- );
- }
-
- $metrics[$user]['nb_tasks']++;
- }
-
- if ($total === 0) {
- return array();
- }
-
- foreach ($metrics as &$metric) {
- $metric['percentage'] = round(($metric['nb_tasks'] * 100) / $total, 2);
- }
-
- ksort($metrics);
-
- return array_values($metrics);
- }
-
- /**
- * Get the average lead and cycle time
- *
- * @access public
- * @param integer $project_id
- * @return array
- */
- public function getAverageLeadAndCycleTime($project_id)
- {
- $stats = array(
- 'count' => 0,
- 'total_lead_time' => 0,
- 'total_cycle_time' => 0,
- 'avg_lead_time' => 0,
- 'avg_cycle_time' => 0,
- );
-
- $tasks = $this->db
- ->table(Task::TABLE)
- ->columns('date_completed', 'date_creation', 'date_started')
- ->eq('project_id', $project_id)
- ->desc('id')
- ->limit(1000)
- ->findAll();
-
- foreach ($tasks as &$task) {
- $stats['count']++;
- $stats['total_lead_time'] += ($task['date_completed'] ?: time()) - $task['date_creation'];
- $stats['total_cycle_time'] += empty($task['date_started']) ? 0 : ($task['date_completed'] ?: time()) - $task['date_started'];
- }
-
- $stats['avg_lead_time'] = $stats['count'] > 0 ? (int) ($stats['total_lead_time'] / $stats['count']) : 0;
- $stats['avg_cycle_time'] = $stats['count'] > 0 ? (int) ($stats['total_cycle_time'] / $stats['count']) : 0;
-
- return $stats;
- }
-
- /**
- * Get the average time spent into each column
- *
- * @access public
- * @param integer $project_id
- * @return array
- */
- public function getAverageTimeSpentByColumn($project_id)
- {
- $stats = array();
- $columns = $this->board->getColumnsList($project_id);
-
- // Get the time spent of the last move for each tasks
- $tasks = $this->db
- ->table(Task::TABLE)
- ->columns('id', 'date_completed', 'date_moved', 'column_id')
- ->eq('project_id', $project_id)
- ->desc('id')
- ->limit(1000)
- ->findAll();
-
- // Init values
- foreach ($columns as $column_id => $column_title) {
- $stats[$column_id] = array(
- 'count' => 0,
- 'time_spent' => 0,
- 'average' => 0,
- 'title' => $column_title,
- );
- }
-
- // Get time spent foreach task/column and take into account the last move
- foreach ($tasks as &$task) {
- $sums = $this->transition->getTimeSpentByTask($task['id']);
-
- if (! isset($sums[$task['column_id']])) {
- $sums[$task['column_id']] = 0;
- }
-
- $sums[$task['column_id']] += ($task['date_completed'] ?: time()) - $task['date_moved'];
-
- foreach ($sums as $column_id => $time_spent) {
- if (isset($stats[$column_id])) {
- $stats[$column_id]['count']++;
- $stats[$column_id]['time_spent'] += $time_spent;
- }
- }
- }
-
- // Calculate average for each column
- foreach ($columns as $column_id => $column_title) {
- $stats[$column_id]['average'] = $stats[$column_id]['count'] > 0 ? (int) ($stats[$column_id]['time_spent'] / $stats[$column_id]['count']) : 0;
- }
-
- return $stats;
- }
-}
diff --git a/app/Model/ProjectDailyColumnStats.php b/app/Model/ProjectDailyColumnStats.php
index 8ed6137f..2bcc4d55 100644
--- a/app/Model/ProjectDailyColumnStats.php
+++ b/app/Model/ProjectDailyColumnStats.php
@@ -2,8 +2,6 @@
namespace Kanboard\Model;
-use PicoDb\Database;
-
/**
* Project Daily Column Stats
*
@@ -20,7 +18,7 @@ class ProjectDailyColumnStats extends Base
const TABLE = 'project_daily_column_stats';
/**
- * Update daily totals for the project and foreach column
+ * Update daily totals for the project and for each column
*
* "total" is the number open of tasks in the column
* "score" is the sum of tasks score in the column
@@ -32,42 +30,22 @@ class ProjectDailyColumnStats extends Base
*/
public function updateTotals($project_id, $date)
{
- $status = $this->config->get('cfd_include_closed_tasks') == 1 ? array(Task::STATUS_OPEN, Task::STATUS_CLOSED) : array(Task::STATUS_OPEN);
-
- return $this->db->transaction(function (Database $db) use ($project_id, $date, $status) {
-
- $column_ids = $db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id');
-
- foreach ($column_ids as $column_id) {
-
- // This call will fail if the record already exists
- // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE)
- $db->table(ProjectDailyColumnStats::TABLE)->insert(array(
- 'day' => $date,
- 'project_id' => $project_id,
- 'column_id' => $column_id,
- 'total' => 0,
- 'score' => 0,
- ));
-
- $db->table(ProjectDailyColumnStats::TABLE)
- ->eq('project_id', $project_id)
- ->eq('column_id', $column_id)
- ->eq('day', $date)
- ->update(array(
- 'score' => $db->table(Task::TABLE)
- ->eq('project_id', $project_id)
- ->eq('column_id', $column_id)
- ->eq('is_active', Task::STATUS_OPEN)
- ->sum('score'),
- 'total' => $db->table(Task::TABLE)
- ->eq('project_id', $project_id)
- ->eq('column_id', $column_id)
- ->in('is_active', $status)
- ->count()
- ));
- }
- });
+ $this->db->startTransaction();
+ $this->db->table(self::TABLE)->eq('project_id', $project_id)->eq('day', $date)->remove();
+
+ foreach ($this->getStatsByColumns($project_id) as $column_id => $column) {
+ $this->db->table(self::TABLE)->insert(array(
+ 'day' => $date,
+ 'project_id' => $project_id,
+ 'column_id' => $column_id,
+ 'total' => $column['total'],
+ 'score' => $column['score'],
+ ));
+ }
+
+ $this->db->closeTransaction();
+
+ return true;
}
/**
@@ -81,43 +59,38 @@ class ProjectDailyColumnStats extends Base
*/
public function countDays($project_id, $from, $to)
{
- $rq = $this->db->execute(
- 'SELECT COUNT(DISTINCT day) FROM '.self::TABLE.' WHERE day >= ? AND day <= ? AND project_id=?',
- array($from, $to, $project_id)
- );
-
- return $rq !== false ? $rq->fetchColumn(0) : 0;
+ return $this->db->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->gte('day', $from)
+ ->lte('day', $to)
+ ->findOneColumn('COUNT(DISTINCT day)');
}
/**
- * Get raw metrics for the project within a data range
+ * Get aggregated metrics for the project within a data range
+ *
+ * [
+ * ['Date', 'Column1', 'Column2'],
+ * ['2014-11-16', 2, 5],
+ * ['2014-11-17', 20, 15],
+ * ]
*
* @access public
* @param integer $project_id Project id
* @param string $from Start date (ISO format YYYY-MM-DD)
* @param string $to End date
+ * @param string $field Column to aggregate
* @return array
*/
- public function getRawMetrics($project_id, $from, $to)
+ public function getAggregatedMetrics($project_id, $from, $to, $field = 'total')
{
- return $this->db->table(ProjectDailyColumnStats::TABLE)
- ->columns(
- ProjectDailyColumnStats::TABLE.'.column_id',
- ProjectDailyColumnStats::TABLE.'.day',
- ProjectDailyColumnStats::TABLE.'.total',
- ProjectDailyColumnStats::TABLE.'.score',
- Board::TABLE.'.title AS column_title'
- )
- ->join(Board::TABLE, 'id', 'column_id')
- ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id)
- ->gte('day', $from)
- ->lte('day', $to)
- ->asc(ProjectDailyColumnStats::TABLE.'.day')
- ->findAll();
+ $columns = $this->column->getList($project_id);
+ $metrics = $this->getMetrics($project_id, $from, $to);
+ return $this->buildAggregate($metrics, $columns, $field);
}
/**
- * Get raw metrics for the project within a data range grouped by day
+ * Fetch metrics
*
* @access public
* @param integer $project_id Project id
@@ -125,72 +98,155 @@ class ProjectDailyColumnStats extends Base
* @param string $to End date
* @return array
*/
- public function getRawMetricsByDay($project_id, $from, $to)
+ public function getMetrics($project_id, $from, $to)
{
- return $this->db->table(ProjectDailyColumnStats::TABLE)
- ->columns(
- ProjectDailyColumnStats::TABLE.'.day',
- 'SUM('.ProjectDailyColumnStats::TABLE.'.total) AS total',
- 'SUM('.ProjectDailyColumnStats::TABLE.'.score) AS score'
- )
- ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id)
- ->gte('day', $from)
- ->lte('day', $to)
- ->asc(ProjectDailyColumnStats::TABLE.'.day')
- ->groupBy(ProjectDailyColumnStats::TABLE.'.day')
- ->findAll();
+ return $this->db->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->gte('day', $from)
+ ->lte('day', $to)
+ ->asc(self::TABLE.'.day')
+ ->findAll();
}
/**
- * Get aggregated metrics for the project within a data range
- *
- * [
- * ['Date', 'Column1', 'Column2'],
- * ['2014-11-16', 2, 5],
- * ['2014-11-17', 20, 15],
- * ]
+ * Build aggregate
*
- * @access public
- * @param integer $project_id Project id
- * @param string $from Start date (ISO format YYYY-MM-DD)
- * @param string $to End date
- * @param string $column Column to aggregate
+ * @access private
+ * @param array $metrics
+ * @param array $columns
+ * @param string $field
* @return array
*/
- public function getAggregatedMetrics($project_id, $from, $to, $column = 'total')
+ private function buildAggregate(array &$metrics, array &$columns, $field)
{
- $columns = $this->board->getColumnsList($project_id);
$column_ids = array_keys($columns);
- $metrics = array(array_merge(array(e('Date')), array_values($columns)));
- $aggregates = array();
-
- // Fetch metrics for the project
- $records = $this->db->table(ProjectDailyColumnStats::TABLE)
- ->eq('project_id', $project_id)
- ->gte('day', $from)
- ->lte('day', $to)
- ->findAll();
-
- // Aggregate by day
- foreach ($records as $record) {
- if (! isset($aggregates[$record['day']])) {
- $aggregates[$record['day']] = array($record['day']);
- }
+ $days = array_unique(array_column($metrics, 'day'));
+ $rows = array(array_merge(array(e('Date')), array_values($columns)));
- $aggregates[$record['day']][$record['column_id']] = $record[$column];
+ foreach ($days as $day) {
+ $rows[] = $this->buildRowAggregate($metrics, $column_ids, $day, $field);
}
- // Aggregate by row
- foreach ($aggregates as $aggregate) {
- $row = array($aggregate[0]);
+ return $rows;
+ }
- foreach ($column_ids as $column_id) {
- $row[] = (int) $aggregate[$column_id];
+ /**
+ * Build one row of the aggregate
+ *
+ * @access private
+ * @param array $metrics
+ * @param array $column_ids
+ * @param string $day
+ * @param string $field
+ * @return array
+ */
+ private function buildRowAggregate(array &$metrics, array &$column_ids, $day, $field)
+ {
+ $row = array($day);
+
+ foreach ($column_ids as $column_id) {
+ $row[] = $this->findValueInMetrics($metrics, $day, $column_id, $field);
+ }
+
+ return $row;
+ }
+
+ /**
+ * Find the value in the metrics
+ *
+ * @access private
+ * @param array $metrics
+ * @param string $day
+ * @param string $column_id
+ * @param string $field
+ * @return integer
+ */
+ private function findValueInMetrics(array &$metrics, $day, $column_id, $field)
+ {
+ foreach ($metrics as $metric) {
+ if ($metric['day'] === $day && $metric['column_id'] == $column_id) {
+ return $metric[$field];
}
+ }
+
+ return 0;
+ }
- $metrics[] = $row;
+ /**
+ * Get number of tasks and score by columns
+ *
+ * @access private
+ * @param integer $project_id
+ * @return array
+ */
+ private function getStatsByColumns($project_id)
+ {
+ $totals = $this->getTotalByColumns($project_id);
+ $scores = $this->getScoreByColumns($project_id);
+ $columns = array();
+
+ foreach ($totals as $column_id => $total) {
+ $columns[$column_id] = array('total' => $total, 'score' => 0);
+ }
+
+ foreach ($scores as $column_id => $score) {
+ $columns[$column_id]['score'] = (int) $score;
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Get number of tasks and score by columns
+ *
+ * @access private
+ * @param integer $project_id
+ * @return array
+ */
+ private function getScoreByColumns($project_id)
+ {
+ $stats = $this->db->table(Task::TABLE)
+ ->columns('column_id', 'SUM(score) AS score')
+ ->eq('project_id', $project_id)
+ ->eq('is_active', Task::STATUS_OPEN)
+ ->notNull('score')
+ ->groupBy('column_id')
+ ->findAll();
+
+ return array_column($stats, 'score', 'column_id');
+ }
+
+ /**
+ * Get number of tasks and score by columns
+ *
+ * @access private
+ * @param integer $project_id
+ * @return array
+ */
+ private function getTotalByColumns($project_id)
+ {
+ $stats = $this->db->table(Task::TABLE)
+ ->columns('column_id', 'COUNT(*) AS total')
+ ->eq('project_id', $project_id)
+ ->in('is_active', $this->getTaskStatusConfig())
+ ->groupBy('column_id')
+ ->findAll();
+
+ return array_column($stats, 'total', 'column_id');
+ }
+
+ /**
+ * Get task status to use for total calculation
+ *
+ * @access private
+ * @return array
+ */
+ private function getTaskStatusConfig()
+ {
+ if ($this->config->get('cfd_include_closed_tasks') == 1) {
+ return array(Task::STATUS_OPEN, Task::STATUS_CLOSED);
}
- return $metrics;
+ return array(Task::STATUS_OPEN);
}
}
diff --git a/app/Model/ProjectDailyStats.php b/app/Model/ProjectDailyStats.php
index 46ca0a4b..957ad51d 100644
--- a/app/Model/ProjectDailyStats.php
+++ b/app/Model/ProjectDailyStats.php
@@ -2,8 +2,6 @@
namespace Kanboard\Model;
-use PicoDb\Database;
-
/**
* Project Daily Stats
*
@@ -29,27 +27,22 @@ class ProjectDailyStats extends Base
*/
public function updateTotals($project_id, $date)
{
- $lead_cycle_time = $this->projectAnalytic->getAverageLeadAndCycleTime($project_id);
+ $this->db->startTransaction();
+
+ $lead_cycle_time = $this->averageLeadCycleTimeAnalytic->build($project_id);
+
+ $this->db->table(self::TABLE)->eq('day', $date)->eq('project_id', $project_id)->remove();
- return $this->db->transaction(function (Database $db) use ($project_id, $date, $lead_cycle_time) {
+ $this->db->table(self::TABLE)->insert(array(
+ 'day' => $date,
+ 'project_id' => $project_id,
+ 'avg_lead_time' => $lead_cycle_time['avg_lead_time'],
+ 'avg_cycle_time' => $lead_cycle_time['avg_cycle_time'],
+ ));
- // This call will fail if the record already exists
- // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE)
- $db->table(ProjectDailyStats::TABLE)->insert(array(
- 'day' => $date,
- 'project_id' => $project_id,
- 'avg_lead_time' => 0,
- 'avg_cycle_time' => 0,
- ));
+ $this->db->closeTransaction();
- $db->table(ProjectDailyStats::TABLE)
- ->eq('project_id', $project_id)
- ->eq('day', $date)
- ->update(array(
- 'avg_lead_time' => $lead_cycle_time['avg_lead_time'],
- 'avg_cycle_time' => $lead_cycle_time['avg_cycle_time'],
- ));
- });
+ return true;
}
/**
@@ -64,11 +57,11 @@ class ProjectDailyStats extends Base
public function getRawMetrics($project_id, $from, $to)
{
return $this->db->table(self::TABLE)
- ->columns('day', 'avg_lead_time', 'avg_cycle_time')
- ->eq(self::TABLE.'.project_id', $project_id)
- ->gte('day', $from)
- ->lte('day', $to)
- ->asc(self::TABLE.'.day')
- ->findAll();
+ ->columns('day', 'avg_lead_time', 'avg_cycle_time')
+ ->eq('project_id', $project_id)
+ ->gte('day', $from)
+ ->lte('day', $to)
+ ->asc('day')
+ ->findAll();
}
}
diff --git a/app/Model/ProjectDuplication.php b/app/Model/ProjectDuplication.php
index f0c66834..9c5f80ad 100644
--- a/app/Model/ProjectDuplication.php
+++ b/app/Model/ProjectDuplication.php
@@ -2,6 +2,8 @@
namespace Kanboard\Model;
+use Kanboard\Core\Security\Role;
+
/**
* Project Duplication
*
@@ -12,6 +14,28 @@ namespace Kanboard\Model;
class ProjectDuplication extends Base
{
/**
+ * Get list of optional models to duplicate
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getOptionalSelection()
+ {
+ return array('category', 'projectPermission', 'action', 'swimlane', 'task');
+ }
+
+ /**
+ * Get list of all possible models to duplicate
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getPossibleSelection()
+ {
+ return array('board', 'category', 'projectPermission', 'action', 'swimlane', 'task');
+ }
+
+ /**
* Get a valid project name for the duplication
*
* @access public
@@ -31,78 +55,106 @@ class ProjectDuplication extends Base
}
/**
- * Create a project from another one
- *
- * @param integer $project_id Project Id
- * @return integer Cloned Project Id
- */
- public function copy($project_id)
- {
- $project = $this->project->getById($project_id);
-
- $values = array(
- 'name' => $this->getClonedProjectName($project['name']),
- 'is_active' => true,
- 'last_modified' => 0,
- 'token' => '',
- 'is_public' => 0,
- 'is_private' => empty($project['is_private']) ? 0 : 1,
- );
-
- if (! $this->db->table(Project::TABLE)->save($values)) {
- return 0;
- }
-
- return $this->db->getLastId();
- }
-
- /**
* Clone a project with all settings
*
- * @param integer $project_id Project Id
- * @param array $part_selection Selection of optional project parts to duplicate. Possible options: 'swimlane', 'action', 'category', 'task'
- * @return integer Cloned Project Id
+ * @param integer $src_project_id Project Id
+ * @param array $selection Selection of optional project parts to duplicate
+ * @param integer $owner_id Owner of the project
+ * @param string $name Name of the project
+ * @param boolean $private Force the project to be private
+ * @return integer Cloned Project Id
*/
- public function duplicate($project_id, $part_selection = array('category', 'action'))
+ public function duplicate($src_project_id, $selection = array('projectPermission', 'category', 'action'), $owner_id = 0, $name = null, $private = null)
{
$this->db->startTransaction();
// Get the cloned project Id
- $clone_project_id = $this->copy($project_id);
+ $dst_project_id = $this->copy($src_project_id, $owner_id, $name, $private);
- if (! $clone_project_id) {
+ if ($dst_project_id === false) {
$this->db->cancelTransaction();
return false;
}
// Clone Columns, Categories, Permissions and Actions
- $optional_parts = array('swimlane', 'action', 'category');
- foreach (array('board', 'category', 'projectPermission', 'action', 'swimlane') as $model) {
+ foreach ($this->getPossibleSelection() as $model) {
// Skip if optional part has not been selected
- if (in_array($model, $optional_parts) && ! in_array($model, $part_selection)) {
+ if (in_array($model, $this->getOptionalSelection()) && ! in_array($model, $selection)) {
continue;
}
- if (! $this->$model->duplicate($project_id, $clone_project_id)) {
+ // Skip permissions for private projects
+ if ($private && $model === 'projectPermission') {
+ continue;
+ }
+
+ if (! $this->$model->duplicate($src_project_id, $dst_project_id)) {
$this->db->cancelTransaction();
return false;
}
}
+ if (! $this->makeOwnerManager($dst_project_id, $owner_id)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
$this->db->closeTransaction();
- // Clone Tasks if in $part_selection
- if (in_array('task', $part_selection)) {
- $tasks = $this->taskFinder->getAll($project_id);
+ return (int) $dst_project_id;
+ }
+
+ /**
+ * Create a project from another one
+ *
+ * @access private
+ * @param integer $src_project_id
+ * @param integer $owner_id
+ * @param string $name
+ * @param boolean $private
+ * @return integer
+ */
+ private function copy($src_project_id, $owner_id = 0, $name = null, $private = null)
+ {
+ $project = $this->project->getById($src_project_id);
+ $is_private = empty($project['is_private']) ? 0 : 1;
+
+ $values = array(
+ 'name' => $name ?: $this->getClonedProjectName($project['name']),
+ 'is_active' => 1,
+ 'last_modified' => time(),
+ 'token' => '',
+ 'is_public' => 0,
+ 'is_private' => $private ? 1 : $is_private,
+ 'owner_id' => $owner_id,
+ );
+
+ if (! $this->db->table(Project::TABLE)->save($values)) {
+ return false;
+ }
+
+ return $this->db->getLastId();
+ }
+
+ /**
+ * Make sure that the creator of the duplicated project is alsp owner
+ *
+ * @access private
+ * @param integer $dst_project_id
+ * @param integer $owner_id
+ * @return boolean
+ */
+ private function makeOwnerManager($dst_project_id, $owner_id)
+ {
+ if ($owner_id > 0) {
+ $this->projectUserRole->removeUser($dst_project_id, $owner_id);
- foreach ($tasks as $task) {
- if (! $this->taskDuplication->duplicateToProject($task['id'], $clone_project_id)) {
- return false;
- }
+ if (! $this->projectUserRole->addUser($dst_project_id, $owner_id, Role::PROJECT_MANAGER)) {
+ return false;
}
}
- return (int) $clone_project_id;
+ return true;
}
}
diff --git a/app/Model/ProjectFile.php b/app/Model/ProjectFile.php
new file mode 100644
index 00000000..aa9bf15b
--- /dev/null
+++ b/app/Model/ProjectFile.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Project File Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectFile extends File
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'project_has_files';
+
+ /**
+ * SQL foreign key
+ *
+ * @var string
+ */
+ const FOREIGN_KEY = 'project_id';
+
+ /**
+ * Path prefix
+ *
+ * @var string
+ */
+ const PATH_PREFIX = 'projects';
+
+ /**
+ * Events
+ *
+ * @var string
+ */
+ const EVENT_CREATE = 'project.file.create';
+}
diff --git a/app/Model/ProjectGroupRole.php b/app/Model/ProjectGroupRole.php
new file mode 100644
index 00000000..750ba7fb
--- /dev/null
+++ b/app/Model/ProjectGroupRole.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Kanboard\Model;
+
+use Kanboard\Core\Security\Role;
+
+/**
+ * Project Group Role
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectGroupRole extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'project_has_groups';
+
+ /**
+ * Get the list of project visible by the given user according to groups
+ *
+ * @access public
+ * @param integer $user_id
+ * @param array $status
+ * @return array
+ */
+ public function getProjectsByUser($user_id, $status = array(Project::ACTIVE, Project::INACTIVE))
+ {
+ return $this->db
+ ->hashtable(Project::TABLE)
+ ->join(self::TABLE, 'project_id', 'id')
+ ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE)
+ ->eq(GroupMember::TABLE.'.user_id', $user_id)
+ ->in(Project::TABLE.'.is_active', $status)
+ ->getAll(Project::TABLE.'.id', Project::TABLE.'.name');
+ }
+
+ /**
+ * For a given project get the role of the specified user
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $user_id
+ * @return string
+ */
+ public function getUserRole($project_id, $user_id)
+ {
+ $roles = $this->db->table(self::TABLE)
+ ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE)
+ ->eq(GroupMember::TABLE.'.user_id', $user_id)
+ ->eq(self::TABLE.'.project_id', $project_id)
+ ->findAllByColumn('role');
+
+ return $this->projectAccessMap->getHighestRole($roles);
+ }
+
+ /**
+ * Get all groups associated directly to the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getGroups($project_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->columns(Group::TABLE.'.id', Group::TABLE.'.name', self::TABLE.'.role')
+ ->join(Group::TABLE, 'id', 'group_id')
+ ->eq('project_id', $project_id)
+ ->asc('name')
+ ->findAll();
+ }
+
+ /**
+ * From groups get all users associated to the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getUsers($project_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.role')
+ ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE)
+ ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE)
+ ->eq(self::TABLE.'.project_id', $project_id)
+ ->asc(User::TABLE.'.username')
+ ->findAll();
+ }
+
+ /**
+ * From groups get all users assignable to tasks
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getAssignableUsers($project_id)
+ {
+ return $this->db->table(User::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name')
+ ->join(GroupMember::TABLE, 'user_id', 'id', User::TABLE)
+ ->join(self::TABLE, 'group_id', 'group_id', GroupMember::TABLE)
+ ->eq(self::TABLE.'.project_id', $project_id)
+ ->eq(User::TABLE.'.is_active', 1)
+ ->in(self::TABLE.'.role', array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER))
+ ->asc(User::TABLE.'.username')
+ ->findAll();
+ }
+
+ /**
+ * Add a group to the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $group_id
+ * @param string $role
+ * @return boolean
+ */
+ public function addGroup($project_id, $group_id, $role)
+ {
+ return $this->db->table(self::TABLE)->insert(array(
+ 'group_id' => $group_id,
+ 'project_id' => $project_id,
+ 'role' => $role,
+ ));
+ }
+
+ /**
+ * Remove a group from the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $group_id
+ * @return boolean
+ */
+ public function removeGroup($project_id, $group_id)
+ {
+ return $this->db->table(self::TABLE)->eq('group_id', $group_id)->eq('project_id', $project_id)->remove();
+ }
+
+ /**
+ * Change a group role for the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $group_id
+ * @param string $role
+ * @return boolean
+ */
+ public function changeGroupRole($project_id, $group_id, $role)
+ {
+ return $this->db->table(self::TABLE)
+ ->eq('group_id', $group_id)
+ ->eq('project_id', $project_id)
+ ->update(array(
+ 'role' => $role,
+ ));
+ }
+
+ /**
+ * Copy group access from a project to another one
+ *
+ * @param integer $project_src_id Project Template
+ * @return integer $project_dst_id Project that receives the copy
+ * @return boolean
+ */
+ public function duplicate($project_src_id, $project_dst_id)
+ {
+ $rows = $this->db->table(self::TABLE)->eq('project_id', $project_src_id)->findAll();
+
+ foreach ($rows as $row) {
+ $result = $this->db->table(self::TABLE)->save(array(
+ 'project_id' => $project_dst_id,
+ 'group_id' => $row['group_id'],
+ 'role' => $row['role'],
+ ));
+
+ if (! $result) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/app/Model/ProjectGroupRoleFilter.php b/app/Model/ProjectGroupRoleFilter.php
new file mode 100644
index 00000000..989d3073
--- /dev/null
+++ b/app/Model/ProjectGroupRoleFilter.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Project Group Role Filter
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectGroupRoleFilter extends Base
+{
+ /**
+ * Query
+ *
+ * @access protected
+ * @var \PicoDb\Table
+ */
+ protected $query;
+
+ /**
+ * Initialize filter
+ *
+ * @access public
+ * @return UserFilter
+ */
+ public function create()
+ {
+ $this->query = $this->db->table(ProjectGroupRole::TABLE);
+ return $this;
+ }
+
+ /**
+ * Get all results of the filter
+ *
+ * @access public
+ * @param string $column
+ * @return array
+ */
+ public function findAll($column = '')
+ {
+ if ($column !== '') {
+ return $this->query->asc($column)->findAllByColumn($column);
+ }
+
+ return $this->query->findAll();
+ }
+
+ /**
+ * Get the PicoDb query
+ *
+ * @access public
+ * @return \PicoDb\Table
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+
+ /**
+ * Filter by project id
+ *
+ * @access public
+ * @param integer $project_id
+ * @return ProjectUserRoleFilter
+ */
+ public function filterByProjectId($project_id)
+ {
+ $this->query->eq(ProjectGroupRole::TABLE.'.project_id', $project_id);
+ return $this;
+ }
+
+ /**
+ * Filter by username
+ *
+ * @access public
+ * @param string $input
+ * @return ProjectUserRoleFilter
+ */
+ public function startWithUsername($input)
+ {
+ $this->query
+ ->join(GroupMember::TABLE, 'group_id', 'group_id', ProjectGroupRole::TABLE)
+ ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE)
+ ->ilike(User::TABLE.'.username', $input.'%');
+
+ return $this;
+ }
+}
diff --git a/app/Model/ProjectPermission.php b/app/Model/ProjectPermission.php
index d9eef4db..db1573ae 100644
--- a/app/Model/ProjectPermission.php
+++ b/app/Model/ProjectPermission.php
@@ -2,11 +2,10 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
+use Kanboard\Core\Security\Role;
/**
- * Project permission model
+ * Project Permission
*
* @package model
* @author Frederic Guillot
@@ -14,117 +13,14 @@ use SimpleValidator\Validators;
class ProjectPermission extends Base
{
/**
- * SQL table name for permissions
- *
- * @var string
- */
- const TABLE = 'project_has_users';
-
- /**
- * Get a list of people that can be assigned for tasks
- *
- * @access public
- * @param integer $project_id Project id
- * @param bool $prepend_unassigned Prepend the 'Unassigned' value
- * @param bool $prepend_everybody Prepend the 'Everbody' value
- * @param bool $allow_single_user If there is only one user return only this user
- * @return array
- */
- public function getMemberList($project_id, $prepend_unassigned = true, $prepend_everybody = false, $allow_single_user = false)
- {
- $allowed_users = $this->getMembers($project_id);
-
- if ($allow_single_user && count($allowed_users) === 1) {
- return $allowed_users;
- }
-
- if ($prepend_unassigned) {
- $allowed_users = array(t('Unassigned')) + $allowed_users;
- }
-
- if ($prepend_everybody) {
- $allowed_users = array(User::EVERYBODY_ID => t('Everybody')) + $allowed_users;
- }
-
- return $allowed_users;
- }
-
- /**
- * Get a list of members and managers with a single SQL query
- *
- * @access public
- * @param integer $project_id Project id
- * @return array
- */
- public function getProjectUsers($project_id)
- {
- $result = array(
- 'managers' => array(),
- 'members' => array(),
- );
-
- $users = $this->db
- ->table(self::TABLE)
- ->join(User::TABLE, 'id', 'user_id')
- ->eq('project_id', $project_id)
- ->asc('username')
- ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.is_owner')
- ->findAll();
-
- foreach ($users as $user) {
- $key = $user['is_owner'] == 1 ? 'managers' : 'members';
- $result[$key][$user['id']] = $user['name'] ?: $user['username'];
- }
-
- return $result;
- }
-
- /**
- * Get a list of allowed people for a project
- *
- * @access public
- * @param integer $project_id Project id
- * @return array
- */
- public function getMembers($project_id)
- {
- if ($this->isEverybodyAllowed($project_id)) {
- return $this->user->getList();
- }
-
- return $this->getAssociatedUsers($project_id);
- }
-
- /**
- * Get a list of owners for a project
- *
- * @access public
- * @param integer $project_id Project id
- * @return array
- */
- public function getManagers($project_id)
- {
- $users = $this->db
- ->table(self::TABLE)
- ->join(User::TABLE, 'id', 'user_id')
- ->eq('project_id', $project_id)
- ->eq('is_owner', 1)
- ->asc('username')
- ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name')
- ->findAll();
-
- return $this->user->prepareList($users);
- }
-
- /**
* Get query for project users overview
*
* @access public
* @param array $project_ids
- * @param integer $is_owner
+ * @param string $role
* @return \PicoDb\Table
*/
- public function getQueryByRole(array $project_ids, $is_owner = 0)
+ public function getQueryByRole(array $project_ids, $role)
{
if (empty($project_ids)) {
$project_ids = array(-1);
@@ -132,10 +28,10 @@ class ProjectPermission extends Base
return $this
->db
- ->table(self::TABLE)
+ ->table(ProjectUserRole::TABLE)
->join(User::TABLE, 'id', 'user_id')
->join(Project::TABLE, 'id', 'project_id')
- ->eq(self::TABLE.'.is_owner', $is_owner)
+ ->eq(ProjectUserRole::TABLE.'.role', $role)
->eq(Project::TABLE.'.is_private', 0)
->in(Project::TABLE.'.id', $project_ids)
->columns(
@@ -148,169 +44,22 @@ class ProjectPermission extends Base
}
/**
- * Get a list of people associated to the project
+ * Get all usernames (fetch users from groups)
*
* @access public
- * @param integer $project_id Project id
+ * @param integer $project_id
+ * @param string $input
* @return array
*/
- public function getAssociatedUsers($project_id)
+ public function findUsernames($project_id, $input)
{
- $users = $this->db
- ->table(self::TABLE)
- ->join(User::TABLE, 'id', 'user_id')
- ->eq('project_id', $project_id)
- ->asc('username')
- ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name')
- ->findAll();
+ $userMembers = $this->projectUserRoleFilter->create()->filterByProjectId($project_id)->startWithUsername($input)->findAll('username');
+ $groupMembers = $this->projectGroupRoleFilter->create()->filterByProjectId($project_id)->startWithUsername($input)->findAll('username');
+ $members = array_unique(array_merge($userMembers, $groupMembers));
- return $this->user->prepareList($users);
- }
+ sort($members);
- /**
- * Get allowed and not allowed users for a project
- *
- * @access public
- * @param integer $project_id Project id
- * @return array
- */
- public function getAllUsers($project_id)
- {
- $users = array(
- 'allowed' => array(),
- 'not_allowed' => array(),
- 'managers' => array(),
- );
-
- $all_users = $this->user->getList();
-
- $users['allowed'] = $this->getMembers($project_id);
- $users['managers'] = $this->getManagers($project_id);
-
- foreach ($all_users as $user_id => $username) {
- if (! isset($users['allowed'][$user_id])) {
- $users['not_allowed'][$user_id] = $username;
- }
- }
-
- return $users;
- }
-
- /**
- * Add a new project member
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @return bool
- */
- public function addMember($project_id, $user_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->save(array('project_id' => $project_id, 'user_id' => $user_id));
- }
-
- /**
- * Remove a member
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @return bool
- */
- public function revokeMember($project_id, $user_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('user_id', $user_id)
- ->remove();
- }
-
- /**
- * Add a project manager
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @return bool
- */
- public function addManager($project_id, $user_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->save(array('project_id' => $project_id, 'user_id' => $user_id, 'is_owner' => 1));
- }
-
- /**
- * Change the role of a member
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @param integer $is_owner Is user owner of the project
- * @return bool
- */
- public function changeRole($project_id, $user_id, $is_owner)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('user_id', $user_id)
- ->update(array('is_owner' => (int) $is_owner));
- }
-
- /**
- * Check if a specific user is member of a project
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @return bool
- */
- public function isMember($project_id, $user_id)
- {
- if ($this->isEverybodyAllowed($project_id)) {
- return true;
- }
-
- return $this->db
- ->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('user_id', $user_id)
- ->exists();
- }
-
- /**
- * Check if a specific user is manager of a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @return bool
- */
- public function isManager($project_id, $user_id)
- {
- return $this->db
- ->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('user_id', $user_id)
- ->eq('is_owner', 1)
- ->exists();
- }
-
- /**
- * Check if a specific user is allowed to access to a given project
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $user_id User id
- * @return bool
- */
- public function isUserAllowed($project_id, $user_id)
- {
- return $project_id === 0 || $this->user->isAdmin($user_id) || $this->isMember($project_id, $user_id);
+ return $members;
}
/**
@@ -330,172 +79,73 @@ class ProjectPermission extends Base
}
/**
- * Return a list of allowed active projects for a given user
+ * Return true if the user is allowed to access a project
*
- * @access public
- * @param integer $user_id User id
- * @return array
+ * @param integer $project_id
+ * @param integer $user_id
+ * @return boolean
*/
- public function getAllowedProjects($user_id)
+ public function isUserAllowed($project_id, $user_id)
{
- if ($this->user->isAdmin($user_id)) {
- return $this->project->getListByStatus(Project::ACTIVE);
+ if ($this->userSession->isAdmin()) {
+ return true;
}
- return $this->getActiveMemberProjects($user_id);
- }
-
- /**
- * Return a list of projects where the user is member
- *
- * @access public
- * @param integer $user_id User id
- * @return array
- */
- public function getMemberProjects($user_id)
- {
- return $this->db
- ->hashtable(Project::TABLE)
- ->beginOr()
- ->eq(self::TABLE.'.user_id', $user_id)
- ->eq(Project::TABLE.'.is_everybody_allowed', 1)
- ->closeOr()
- ->join(self::TABLE, 'project_id', 'id')
- ->getAll('projects.id', 'name');
+ return in_array(
+ $this->projectUserRole->getUserRole($project_id, $user_id),
+ array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER, Role::PROJECT_VIEWER)
+ );
}
/**
- * Return a list of project ids where the user is member
+ * Return true if the user is assignable
*
* @access public
- * @param integer $user_id User id
- * @return array
+ * @param integer $project_id
+ * @param integer $user_id
+ * @return boolean
*/
- public function getMemberProjectIds($user_id)
+ public function isAssignable($project_id, $user_id)
{
- return $this->db
- ->table(Project::TABLE)
- ->beginOr()
- ->eq(self::TABLE.'.user_id', $user_id)
- ->eq(Project::TABLE.'.is_everybody_allowed', 1)
- ->closeOr()
- ->join(self::TABLE, 'project_id', 'id')
- ->findAllByColumn('projects.id');
+ return $this->user->isActive($user_id) &&
+ in_array($this->projectUserRole->getUserRole($project_id, $user_id), array(Role::PROJECT_MEMBER, Role::PROJECT_MANAGER));
}
/**
- * Return a list of active project ids where the user is member
+ * Return true if the user is member
*
* @access public
- * @param integer $user_id User id
- * @return array
+ * @param integer $project_id
+ * @param integer $user_id
+ * @return boolean
*/
- public function getActiveMemberProjectIds($user_id)
+ public function isMember($project_id, $user_id)
{
- return $this->db
- ->table(Project::TABLE)
- ->beginOr()
- ->eq(self::TABLE.'.user_id', $user_id)
- ->eq(Project::TABLE.'.is_everybody_allowed', 1)
- ->closeOr()
- ->eq(Project::TABLE.'.is_active', Project::ACTIVE)
- ->join(self::TABLE, 'project_id', 'id')
- ->findAllByColumn('projects.id');
+ return in_array($this->projectUserRole->getUserRole($project_id, $user_id), array(Role::PROJECT_MEMBER, Role::PROJECT_MANAGER, Role::PROJECT_VIEWER));
}
/**
- * Return a list of active projects where the user is member
+ * Get active project ids by user
*
* @access public
- * @param integer $user_id User id
+ * @param integer $user_id
* @return array
*/
- public function getActiveMemberProjects($user_id)
+ public function getActiveProjectIds($user_id)
{
- return $this->db
- ->hashtable(Project::TABLE)
- ->beginOr()
- ->eq(self::TABLE.'.user_id', $user_id)
- ->eq(Project::TABLE.'.is_everybody_allowed', 1)
- ->closeOr()
- ->eq(Project::TABLE.'.is_active', Project::ACTIVE)
- ->join(self::TABLE, 'project_id', 'id')
- ->getAll('projects.id', 'name');
+ return array_keys($this->projectUserRole->getActiveProjectsByUser($user_id));
}
/**
- * Copy user access from a project to another one
+ * Copy permissions to another project
*
- * @param integer $project_src Project Template
- * @return integer $project_dst Project that receives the copy
+ * @param integer $project_src_id Project Template
+ * @param integer $project_dst_id Project that receives the copy
* @return boolean
*/
- public function duplicate($project_src, $project_dst)
- {
- $rows = $this->db
- ->table(self::TABLE)
- ->columns('project_id', 'user_id', 'is_owner')
- ->eq('project_id', $project_src)
- ->findAll();
-
- foreach ($rows as $row) {
- $result = $this->db
- ->table(self::TABLE)
- ->save(array(
- 'project_id' => $project_dst,
- 'user_id' => $row['user_id'],
- 'is_owner' => (int) $row['is_owner'], // (int) for postgres
- ));
-
- if (! $result) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * Validate allow user
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateUserModification(array $values)
- {
- $v = new Validator($values, array(
- new Validators\Required('project_id', t('The project id is required')),
- new Validators\Integer('project_id', t('This value must be an integer')),
- new Validators\Required('user_id', t('The user id is required')),
- new Validators\Integer('user_id', t('This value must be an integer')),
- new Validators\Integer('is_owner', t('This value must be an integer')),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate allow everybody
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateProjectModification(array $values)
+ public function duplicate($project_src_id, $project_dst_id)
{
- $v = new Validator($values, array(
- new Validators\Required('id', t('The project id is required')),
- new Validators\Integer('id', t('This value must be an integer')),
- new Validators\Integer('is_everybody_allowed', t('This value must be an integer')),
- ));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
+ return $this->projectUserRole->duplicate($project_src_id, $project_dst_id) &&
+ $this->projectGroupRole->duplicate($project_src_id, $project_dst_id);
}
}
diff --git a/app/Model/ProjectUserRole.php b/app/Model/ProjectUserRole.php
new file mode 100644
index 00000000..56da679c
--- /dev/null
+++ b/app/Model/ProjectUserRole.php
@@ -0,0 +1,276 @@
+<?php
+
+namespace Kanboard\Model;
+
+use Kanboard\Core\Security\Role;
+
+/**
+ * Project User Role
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectUserRole extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'project_has_users';
+
+ /**
+ * Get the list of active project for the given user
+ *
+ * @access public
+ * @param integer $user_id
+ * @return array
+ */
+ public function getActiveProjectsByUser($user_id)
+ {
+ return $this->getProjectsByUser($user_id, array(Project::ACTIVE));
+ }
+
+ /**
+ * Get the list of project visible for the given user
+ *
+ * @access public
+ * @param integer $user_id
+ * @param array $status
+ * @return array
+ */
+ public function getProjectsByUser($user_id, $status = array(Project::ACTIVE, Project::INACTIVE))
+ {
+ $userProjects = $this->db
+ ->hashtable(Project::TABLE)
+ ->beginOr()
+ ->eq(self::TABLE.'.user_id', $user_id)
+ ->eq(Project::TABLE.'.is_everybody_allowed', 1)
+ ->closeOr()
+ ->in(Project::TABLE.'.is_active', $status)
+ ->join(self::TABLE, 'project_id', 'id')
+ ->getAll(Project::TABLE.'.id', Project::TABLE.'.name');
+
+ $groupProjects = $this->projectGroupRole->getProjectsByUser($user_id, $status);
+ $projects = $userProjects + $groupProjects;
+
+ asort($projects);
+
+ return $projects;
+ }
+
+ /**
+ * For a given project get the role of the specified user
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $user_id
+ * @return string
+ */
+ public function getUserRole($project_id, $user_id)
+ {
+ if ($this->projectPermission->isEverybodyAllowed($project_id)) {
+ return Role::PROJECT_MEMBER;
+ }
+
+ $role = $this->db->table(self::TABLE)->eq('user_id', $user_id)->eq('project_id', $project_id)->findOneColumn('role');
+
+ if (empty($role)) {
+ $role = $this->projectGroupRole->getUserRole($project_id, $user_id);
+ }
+
+ return $role;
+ }
+
+ /**
+ * Get all users associated directly to the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getUsers($project_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.role')
+ ->join(User::TABLE, 'id', 'user_id')
+ ->eq('project_id', $project_id)
+ ->asc(User::TABLE.'.username')
+ ->asc(User::TABLE.'.name')
+ ->findAll();
+ }
+
+ /**
+ * Get all users (fetch users from groups)
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getAllUsers($project_id)
+ {
+ $userMembers = $this->getUsers($project_id);
+ $groupMembers = $this->projectGroupRole->getUsers($project_id);
+ $members = array_merge($userMembers, $groupMembers);
+
+ return $this->user->prepareList($members);
+ }
+
+ /**
+ * Get users grouped by role
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getAllUsersGroupedByRole($project_id)
+ {
+ $users = array();
+
+ $userMembers = $this->getUsers($project_id);
+ $groupMembers = $this->projectGroupRole->getUsers($project_id);
+ $members = array_merge($userMembers, $groupMembers);
+
+ foreach ($members as $user) {
+ if (! isset($users[$user['role']])) {
+ $users[$user['role']] = array();
+ }
+
+ $users[$user['role']][$user['id']] = $user['name'] ?: $user['username'];
+ }
+
+ return $users;
+ }
+
+ /**
+ * Get list of users that can be assigned to a task (only Manager and Member)
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getAssignableUsers($project_id)
+ {
+ if ($this->projectPermission->isEverybodyAllowed($project_id)) {
+ return $this->user->getActiveUsersList();
+ }
+
+ $userMembers = $this->db->table(self::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name')
+ ->join(User::TABLE, 'id', 'user_id')
+ ->eq(User::TABLE.'.is_active', 1)
+ ->eq(self::TABLE.'.project_id', $project_id)
+ ->in(self::TABLE.'.role', array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER))
+ ->findAll();
+
+ $groupMembers = $this->projectGroupRole->getAssignableUsers($project_id);
+ $members = array_merge($userMembers, $groupMembers);
+
+ return $this->user->prepareList($members);
+ }
+
+ /**
+ * Get list of users that can be assigned to a task (only Manager and Member)
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param bool $unassigned Prepend the 'Unassigned' value
+ * @param bool $everybody Prepend the 'Everbody' value
+ * @param bool $singleUser If there is only one user return only this user
+ * @return array
+ */
+ public function getAssignableUsersList($project_id, $unassigned = true, $everybody = false, $singleUser = false)
+ {
+ $users = $this->getAssignableUsers($project_id);
+
+ if ($singleUser && count($users) === 1) {
+ return $users;
+ }
+
+ if ($unassigned) {
+ $users = array(t('Unassigned')) + $users;
+ }
+
+ if ($everybody) {
+ $users = array(User::EVERYBODY_ID => t('Everybody')) + $users;
+ }
+
+ return $users;
+ }
+
+ /**
+ * Add a user to the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $user_id
+ * @param string $role
+ * @return boolean
+ */
+ public function addUser($project_id, $user_id, $role)
+ {
+ return $this->db->table(self::TABLE)->insert(array(
+ 'user_id' => $user_id,
+ 'project_id' => $project_id,
+ 'role' => $role,
+ ));
+ }
+
+ /**
+ * Remove a user from the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function removeUser($project_id, $user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('user_id', $user_id)->eq('project_id', $project_id)->remove();
+ }
+
+ /**
+ * Change a user role for the project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $user_id
+ * @param string $role
+ * @return boolean
+ */
+ public function changeUserRole($project_id, $user_id, $role)
+ {
+ return $this->db->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->eq('project_id', $project_id)
+ ->update(array(
+ 'role' => $role,
+ ));
+ }
+
+ /**
+ * Copy user access from a project to another one
+ *
+ * @param integer $project_src_id Project Template
+ * @return integer $project_dst_id Project that receives the copy
+ * @return boolean
+ */
+ public function duplicate($project_src_id, $project_dst_id)
+ {
+ $rows = $this->db->table(self::TABLE)->eq('project_id', $project_src_id)->findAll();
+
+ foreach ($rows as $row) {
+ $result = $this->db->table(self::TABLE)->save(array(
+ 'project_id' => $project_dst_id,
+ 'user_id' => $row['user_id'],
+ 'role' => $row['role'],
+ ));
+
+ if (! $result) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/app/Model/ProjectUserRoleFilter.php b/app/Model/ProjectUserRoleFilter.php
new file mode 100644
index 00000000..64403643
--- /dev/null
+++ b/app/Model/ProjectUserRoleFilter.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Project User Role Filter
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectUserRoleFilter extends Base
+{
+ /**
+ * Query
+ *
+ * @access protected
+ * @var \PicoDb\Table
+ */
+ protected $query;
+
+ /**
+ * Initialize filter
+ *
+ * @access public
+ * @return UserFilter
+ */
+ public function create()
+ {
+ $this->query = $this->db->table(ProjectUserRole::TABLE);
+ return $this;
+ }
+
+ /**
+ * Get all results of the filter
+ *
+ * @access public
+ * @param string $column
+ * @return array
+ */
+ public function findAll($column = '')
+ {
+ if ($column !== '') {
+ return $this->query->asc($column)->findAllByColumn($column);
+ }
+
+ return $this->query->findAll();
+ }
+
+ /**
+ * Get the PicoDb query
+ *
+ * @access public
+ * @return \PicoDb\Table
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+
+ /**
+ * Filter by project id
+ *
+ * @access public
+ * @param integer $project_id
+ * @return ProjectUserRoleFilter
+ */
+ public function filterByProjectId($project_id)
+ {
+ $this->query->eq(ProjectUserRole::TABLE.'.project_id', $project_id);
+ return $this;
+ }
+
+ /**
+ * Filter by username
+ *
+ * @access public
+ * @param string $input
+ * @return ProjectUserRoleFilter
+ */
+ public function startWithUsername($input)
+ {
+ $this->query
+ ->join(User::TABLE, 'id', 'user_id')
+ ->ilike(User::TABLE.'.username', $input.'%');
+
+ return $this;
+ }
+}
diff --git a/app/Model/RememberMeSession.php b/app/Model/RememberMeSession.php
new file mode 100644
index 00000000..8989a6d7
--- /dev/null
+++ b/app/Model/RememberMeSession.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Kanboard\Model;
+
+use Kanboard\Core\Security\Token;
+
+/**
+ * Remember Me Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class RememberMeSession extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'remember_me';
+
+ /**
+ * Expiration (60 days)
+ *
+ * @var integer
+ */
+ const EXPIRATION = 5184000;
+
+ /**
+ * Get a remember me record
+ *
+ * @access public
+ * @param $token
+ * @param $sequence
+ * @return mixed
+ */
+ public function find($token, $sequence)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('token', $token)
+ ->eq('sequence', $sequence)
+ ->gt('expiration', time())
+ ->findOne();
+ }
+
+ /**
+ * Get all sessions for a given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function getAll($user_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->desc('date_creation')
+ ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration')
+ ->findAll();
+ }
+
+ /**
+ * Create a new RememberMe session
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @param string $ip IP Address
+ * @param string $user_agent User Agent
+ * @return array
+ */
+ public function create($user_id, $ip, $user_agent)
+ {
+ $token = hash('sha256', $user_id.$user_agent.$ip.Token::getToken());
+ $sequence = Token::getToken();
+ $expiration = time() + self::EXPIRATION;
+
+ $this->cleanup($user_id);
+
+ $this
+ ->db
+ ->table(self::TABLE)
+ ->insert(array(
+ 'user_id' => $user_id,
+ 'ip' => $ip,
+ 'user_agent' => $user_agent,
+ 'token' => $token,
+ 'sequence' => $sequence,
+ 'expiration' => $expiration,
+ 'date_creation' => time(),
+ ));
+
+ return array(
+ 'token' => $token,
+ 'sequence' => $sequence,
+ 'expiration' => $expiration,
+ );
+ }
+
+ /**
+ * Remove a session record
+ *
+ * @access public
+ * @param integer $session_id Session id
+ * @return mixed
+ */
+ public function remove($session_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $session_id)
+ ->remove();
+ }
+
+ /**
+ * Remove old sessions for a given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return bool
+ */
+ public function cleanup($user_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->lt('expiration', time())
+ ->remove();
+ }
+
+ /**
+ * Return a new sequence token and update the database
+ *
+ * @access public
+ * @param string $token Session token
+ * @return string
+ */
+ public function updateSequence($token)
+ {
+ $sequence = Token::getToken();
+
+ $this
+ ->db
+ ->table(self::TABLE)
+ ->eq('token', $token)
+ ->update(array('sequence' => $sequence));
+
+ return $sequence;
+ }
+}
diff --git a/app/Model/Setting.php b/app/Model/Setting.php
index 3507d424..44e6c065 100644
--- a/app/Model/Setting.php
+++ b/app/Model/Setting.php
@@ -47,10 +47,12 @@ abstract class Setting extends Base
*/
public function getOption($name, $default = '')
{
- return $this->db
+ $value = $this->db
->table(self::TABLE)
->eq('option', $name)
- ->findOneColumn('value') ?: $default;
+ ->findOneColumn('value');
+
+ return $value === null || $value === false || $value === '' ? $default : $value;
}
/**
diff --git a/app/Model/Subtask.php b/app/Model/Subtask.php
index 664e41e1..b5898fcf 100644
--- a/app/Model/Subtask.php
+++ b/app/Model/Subtask.php
@@ -4,11 +4,9 @@ namespace Kanboard\Model;
use PicoDb\Database;
use Kanboard\Event\SubtaskEvent;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
/**
- * Subtask model
+ * Subtask Model
*
* @package model
* @author Frederic Guillot
@@ -265,89 +263,36 @@ class Subtask extends Base
}
/**
- * Get subtasks with consecutive positions
- *
- * If you remove a subtask, the positions are not anymore consecutives
- *
- * @access public
- * @param integer $task_id
- * @return array
- */
- public function getNormalizedPositions($task_id)
- {
- $subtasks = $this->db->hashtable(self::TABLE)->eq('task_id', $task_id)->asc('position')->getAll('id', 'position');
- $position = 1;
-
- foreach ($subtasks as $subtask_id => $subtask_position) {
- $subtasks[$subtask_id] = $position++;
- }
-
- return $subtasks;
- }
-
- /**
- * Save the new positions for a set of subtasks
- *
- * @access public
- * @param array $subtasks Hashmap of column_id/column_position
- * @return boolean
- */
- public function savePositions(array $subtasks)
- {
- return $this->db->transaction(function (Database $db) use ($subtasks) {
-
- foreach ($subtasks as $subtask_id => $position) {
- if (! $db->table(Subtask::TABLE)->eq('id', $subtask_id)->update(array('position' => $position))) {
- return false;
- }
- }
- });
- }
-
- /**
- * Move a subtask down, increment the position value
+ * Save subtask position
*
* @access public
* @param integer $task_id
* @param integer $subtask_id
+ * @param integer $position
* @return boolean
*/
- public function moveDown($task_id, $subtask_id)
+ public function changePosition($task_id, $subtask_id, $position)
{
- $subtasks = $this->getNormalizedPositions($task_id);
- $positions = array_flip($subtasks);
-
- if (isset($subtasks[$subtask_id]) && $subtasks[$subtask_id] < count($subtasks)) {
- $position = ++$subtasks[$subtask_id];
- $subtasks[$positions[$position]]--;
-
- return $this->savePositions($subtasks);
+ if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('task_id', $task_id)->count()) {
+ return false;
}
- return false;
- }
-
- /**
- * Move a subtask up, decrement the position value
- *
- * @access public
- * @param integer $task_id
- * @param integer $subtask_id
- * @return boolean
- */
- public function moveUp($task_id, $subtask_id)
- {
- $subtasks = $this->getNormalizedPositions($task_id);
- $positions = array_flip($subtasks);
+ $subtask_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id');
+ $offset = 1;
+ $results = array();
- if (isset($subtasks[$subtask_id]) && $subtasks[$subtask_id] > 1) {
- $position = --$subtasks[$subtask_id];
- $subtasks[$positions[$position]]++;
+ foreach ($subtask_ids as $current_subtask_id) {
+ if ($offset == $position) {
+ $offset++;
+ }
- return $this->savePositions($subtasks);
+ $results[] = $this->db->table(self::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset));
+ $offset++;
}
- return false;
+ $results[] = $this->db->table(self::TABLE)->eq('id', $subtask_id)->update(array('position' => $position));
+
+ return !in_array(false, $results, true);
}
/**
@@ -355,15 +300,16 @@ class Subtask extends Base
*
* @access public
* @param integer $subtask_id
- * @return bool
+ * @return boolean|integer
*/
public function toggleStatus($subtask_id)
{
$subtask = $this->getById($subtask_id);
+ $status = ($subtask['status'] + 1) % 3;
$values = array(
'id' => $subtask['id'],
- 'status' => ($subtask['status'] + 1) % 3,
+ 'status' => $status,
'task_id' => $subtask['task_id'],
);
@@ -371,7 +317,7 @@ class Subtask extends Base
$values['user_id'] = $this->userSession->getId();
}
- return $this->update($values);
+ return $this->update($values) ? $status : false;
}
/**
@@ -437,10 +383,10 @@ class Subtask extends Base
return $this->db->transaction(function (Database $db) use ($src_task_id, $dst_task_id) {
$subtasks = $db->table(Subtask::TABLE)
- ->columns('title', 'time_estimated', 'position')
- ->eq('task_id', $src_task_id)
- ->asc('position')
- ->findAll();
+ ->columns('title', 'time_estimated', 'position')
+ ->eq('task_id', $src_task_id)
+ ->asc('position')
+ ->findAll();
foreach ($subtasks as &$subtask) {
$subtask['task_id'] = $dst_task_id;
@@ -451,90 +397,4 @@ class Subtask extends Base
}
});
}
-
- /**
- * Validate creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $rules = array(
- new Validators\Required('task_id', t('The task id is required')),
- new Validators\Required('title', t('The title is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The subtask id is required')),
- new Validators\Required('task_id', t('The task id is required')),
- new Validators\Required('title', t('The title is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate API modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateApiModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The subtask id is required')),
- new Validators\Required('task_id', t('The task id is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Integer('id', t('The subtask id must be an integer')),
- new Validators\Integer('task_id', t('The task id must be an integer')),
- new Validators\MaxLength('title', t('The maximum length is %d characters', 255), 255),
- new Validators\Integer('user_id', t('The user id must be an integer')),
- new Validators\Integer('status', t('The status must be an integer')),
- new Validators\Numeric('time_estimated', t('The time must be a numeric value')),
- new Validators\Numeric('time_spent', t('The time must be a numeric value')),
- );
- }
}
diff --git a/app/Model/SubtaskTimeTracking.php b/app/Model/SubtaskTimeTracking.php
index a741dbb5..b766b542 100644
--- a/app/Model/SubtaskTimeTracking.php
+++ b/app/Model/SubtaskTimeTracking.php
@@ -302,11 +302,11 @@ class SubtaskTimeTracking extends Base
{
$hook = 'model:subtask-time-tracking:calculate:time-spent';
$start_time = $this->db
- ->table(self::TABLE)
- ->eq('subtask_id', $subtask_id)
- ->eq('user_id', $user_id)
- ->eq('end', 0)
- ->findOneColumn('start');
+ ->table(self::TABLE)
+ ->eq('subtask_id', $subtask_id)
+ ->eq('user_id', $user_id)
+ ->eq('end', 0)
+ ->findOneColumn('start');
if (empty($start_time)) {
return 0;
diff --git a/app/Model/Swimlane.php b/app/Model/Swimlane.php
index df44985a..721f20d3 100644
--- a/app/Model/Swimlane.php
+++ b/app/Model/Swimlane.php
@@ -2,9 +2,6 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-
/**
* Swimlanes
*
@@ -99,10 +96,11 @@ class Swimlane extends Base
*/
public function getDefault($project_id)
{
- $result = $this->db->table(Project::TABLE)
- ->eq('id', $project_id)
- ->columns('id', 'default_swimlane', 'show_default_swimlane')
- ->findOne();
+ $result = $this->db
+ ->table(Project::TABLE)
+ ->eq('id', $project_id)
+ ->columns('id', 'default_swimlane', 'show_default_swimlane')
+ ->findOne();
if ($result['default_swimlane'] === 'Default swimlane') {
$result['default_swimlane'] = t($result['default_swimlane']);
@@ -120,10 +118,11 @@ class Swimlane extends Base
*/
public function getAll($project_id)
{
- return $this->db->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->orderBy('position', 'asc')
- ->findAll();
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->orderBy('position', 'asc')
+ ->findAll();
}
/**
@@ -136,9 +135,10 @@ class Swimlane extends Base
*/
public function getAllByStatus($project_id, $status = self::ACTIVE)
{
- $query = $this->db->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('is_active', $status);
+ $query = $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('is_active', $status);
if ($status == self::ACTIVE) {
$query->asc('position');
@@ -158,17 +158,19 @@ class Swimlane extends Base
*/
public function getSwimlanes($project_id)
{
- $swimlanes = $this->db->table(self::TABLE)
- ->columns('id', 'name', 'description')
- ->eq('project_id', $project_id)
- ->eq('is_active', self::ACTIVE)
- ->orderBy('position', 'asc')
- ->findAll();
-
- $default_swimlane = $this->db->table(Project::TABLE)
- ->eq('id', $project_id)
- ->eq('show_default_swimlane', 1)
- ->findOneColumn('default_swimlane');
+ $swimlanes = $this->db
+ ->table(self::TABLE)
+ ->columns('id', 'name', 'description')
+ ->eq('project_id', $project_id)
+ ->eq('is_active', self::ACTIVE)
+ ->orderBy('position', 'asc')
+ ->findAll();
+
+ $default_swimlane = $this->db
+ ->table(Project::TABLE)
+ ->eq('id', $project_id)
+ ->eq('show_default_swimlane', 1)
+ ->findOneColumn('default_swimlane');
if ($default_swimlane) {
if ($default_swimlane === 'Default swimlane') {
@@ -203,11 +205,12 @@ class Swimlane extends Base
$swimlanes[0] = $default === 'Default swimlane' ? t($default) : $default;
}
- return $swimlanes + $this->db->hashtable(self::TABLE)
- ->eq('project_id', $project_id)
- ->in('is_active', $only_active ? array(self::ACTIVE) : array(self::ACTIVE, self::INACTIVE))
- ->orderBy('position', 'asc')
- ->getAll('id', 'name');
+ return $swimlanes + $this->db
+ ->hashtable(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->in('is_active', $only_active ? array(self::ACTIVE) : array(self::ACTIVE, self::INACTIVE))
+ ->orderBy('position', 'asc')
+ ->getAll('id', 'name');
}
/**
@@ -235,9 +238,10 @@ class Swimlane extends Base
*/
public function update(array $values)
{
- return $this->db->table(self::TABLE)
- ->eq('id', $values['id'])
- ->update($values);
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $values['id'])
+ ->update($values);
}
/**
@@ -250,12 +254,46 @@ class Swimlane extends Base
public function updateDefault(array $values)
{
return $this->db
- ->table(Project::TABLE)
- ->eq('id', $values['id'])
- ->update(array(
- 'default_swimlane' => $values['default_swimlane'],
- 'show_default_swimlane' => $values['show_default_swimlane'],
- ));
+ ->table(Project::TABLE)
+ ->eq('id', $values['id'])
+ ->update(array(
+ 'default_swimlane' => $values['default_swimlane'],
+ 'show_default_swimlane' => $values['show_default_swimlane'],
+ ));
+ }
+
+ /**
+ * Enable the default swimlane
+ *
+ * @access public
+ * @param integer $project_id
+ * @return bool
+ */
+ public function enableDefault($project_id)
+ {
+ return $this->db
+ ->table(Project::TABLE)
+ ->eq('id', $project_id)
+ ->update(array(
+ 'show_default_swimlane' => 1,
+ ));
+ }
+
+ /**
+ * Disable the default swimlane
+ *
+ * @access public
+ * @param integer $project_id
+ * @return bool
+ */
+ public function disableDefault($project_id)
+ {
+ return $this->db
+ ->table(Project::TABLE)
+ ->eq('id', $project_id)
+ ->update(array(
+ 'show_default_swimlane' => 0,
+ ));
}
/**
@@ -267,10 +305,11 @@ class Swimlane extends Base
*/
public function getLastPosition($project_id)
{
- return $this->db->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('is_active', 1)
- ->count() + 1;
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('is_active', 1)
+ ->count() + 1;
}
/**
@@ -284,12 +323,12 @@ class Swimlane extends Base
public function disable($project_id, $swimlane_id)
{
$result = $this->db
- ->table(self::TABLE)
- ->eq('id', $swimlane_id)
- ->update(array(
- 'is_active' => self::INACTIVE,
- 'position' => 0,
- ));
+ ->table(self::TABLE)
+ ->eq('id', $swimlane_id)
+ ->update(array(
+ 'is_active' => self::INACTIVE,
+ 'position' => 0,
+ ));
if ($result) {
// Re-order positions
@@ -310,12 +349,12 @@ class Swimlane extends Base
public function enable($project_id, $swimlane_id)
{
return $this->db
- ->table(self::TABLE)
- ->eq('id', $swimlane_id)
- ->update(array(
- 'is_active' => self::ACTIVE,
- 'position' => $this->getLastPosition($project_id),
- ));
+ ->table(self::TABLE)
+ ->eq('id', $swimlane_id)
+ ->update(array(
+ 'is_active' => self::ACTIVE,
+ 'position' => $this->getLastPosition($project_id),
+ ));
}
/**
@@ -356,11 +395,13 @@ class Swimlane extends Base
public function updatePositions($project_id)
{
$position = 0;
- $swimlanes = $this->db->table(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('is_active', 1)
- ->asc('position')
- ->findAllByColumn('id');
+ $swimlanes = $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('is_active', 1)
+ ->asc('position')
+ ->asc('id')
+ ->findAllByColumn('id');
if (! $swimlanes) {
return false;
@@ -368,77 +409,50 @@ class Swimlane extends Base
foreach ($swimlanes as $swimlane_id) {
$this->db->table(self::TABLE)
- ->eq('id', $swimlane_id)
- ->update(array('position' => ++$position));
+ ->eq('id', $swimlane_id)
+ ->update(array('position' => ++$position));
}
return true;
}
/**
- * Move a swimlane down, increment the position value
+ * Change swimlane position
*
* @access public
- * @param integer $project_id Project id
- * @param integer $swimlane_id Swimlane id
+ * @param integer $project_id
+ * @param integer $swimlane_id
+ * @param integer $position
* @return boolean
*/
- public function moveDown($project_id, $swimlane_id)
+ public function changePosition($project_id, $swimlane_id, $position)
{
- $swimlanes = $this->db->hashtable(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('is_active', self::ACTIVE)
- ->asc('position')
- ->getAll('id', 'position');
-
- $positions = array_flip($swimlanes);
-
- if (isset($swimlanes[$swimlane_id]) && $swimlanes[$swimlane_id] < count($swimlanes)) {
- $position = ++$swimlanes[$swimlane_id];
- $swimlanes[$positions[$position]]--;
-
- $this->db->startTransaction();
- $this->db->table(self::TABLE)->eq('id', $swimlane_id)->update(array('position' => $position));
- $this->db->table(self::TABLE)->eq('id', $positions[$position])->update(array('position' => $swimlanes[$positions[$position]]));
- $this->db->closeTransaction();
-
- return true;
+ if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('project_id', $project_id)->count()) {
+ return false;
}
- return false;
- }
+ $swimlane_ids = $this->db->table(self::TABLE)
+ ->eq('is_active', 1)
+ ->eq('project_id', $project_id)
+ ->neq('id', $swimlane_id)
+ ->asc('position')
+ ->findAllByColumn('id');
- /**
- * Move a swimlane up, decrement the position value
- *
- * @access public
- * @param integer $project_id Project id
- * @param integer $swimlane_id Swimlane id
- * @return boolean
- */
- public function moveUp($project_id, $swimlane_id)
- {
- $swimlanes = $this->db->hashtable(self::TABLE)
- ->eq('project_id', $project_id)
- ->eq('is_active', self::ACTIVE)
- ->asc('position')
- ->getAll('id', 'position');
-
- $positions = array_flip($swimlanes);
+ $offset = 1;
+ $results = array();
- if (isset($swimlanes[$swimlane_id]) && $swimlanes[$swimlane_id] > 1) {
- $position = --$swimlanes[$swimlane_id];
- $swimlanes[$positions[$position]]++;
-
- $this->db->startTransaction();
- $this->db->table(self::TABLE)->eq('id', $swimlane_id)->update(array('position' => $position));
- $this->db->table(self::TABLE)->eq('id', $positions[$position])->update(array('position' => $swimlanes[$positions[$position]]));
- $this->db->closeTransaction();
+ foreach ($swimlane_ids as $current_swimlane_id) {
+ if ($offset == $position) {
+ $offset++;
+ }
- return true;
+ $results[] = $this->db->table(self::TABLE)->eq('id', $current_swimlane_id)->update(array('position' => $offset));
+ $offset++;
}
- return false;
+ $results[] = $this->db->table(self::TABLE)->eq('id', $swimlane_id)->update(array('position' => $position));
+
+ return !in_array(false, $results, true);
}
/**
@@ -470,85 +484,4 @@ class Swimlane extends Base
return true;
}
-
- /**
- * Validate creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $rules = array(
- new Validators\Required('project_id', t('The project id is required')),
- new Validators\Required('name', t('The name is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The id is required')),
- new Validators\Required('name', t('The name is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate default swimlane modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateDefaultModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The id is required')),
- new Validators\Required('default_swimlane', t('The name is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Integer('id', t('The id must be an integer')),
- new Validators\Integer('project_id', t('The project id must be an integer')),
- new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50)
- );
- }
}
diff --git a/app/Model/Task.php b/app/Model/Task.php
index f1cd094f..f8b41b9f 100644
--- a/app/Model/Task.php
+++ b/app/Model/Task.php
@@ -41,6 +41,8 @@ class Task extends Base
const EVENT_CREATE_UPDATE = 'task.create_update';
const EVENT_ASSIGNEE_CHANGE = 'task.assignee_change';
const EVENT_OVERDUE = 'task.overdue';
+ const EVENT_USER_MENTION = 'task.user.mention';
+ const EVENT_DAILY_CRONJOB = 'task.cronjob.daily';
/**
* Recurrence: status
@@ -90,7 +92,7 @@ class Task extends Base
return false;
}
- $this->file->removeAll($task_id);
+ $this->taskFile->removeAll($task_id);
return $this->db->table(self::TABLE)->eq('id', $task_id)->remove();
}
@@ -197,4 +199,25 @@ class Task extends Base
return round(($position * 100) / count($columns), 1);
}
+
+ /**
+ * Helper method to duplicate all tasks to another project
+ *
+ * @access public
+ * @param integer $src_project_id
+ * @param integer $dst_project_id
+ * @return boolean
+ */
+ public function duplicate($src_project_id, $dst_project_id)
+ {
+ $task_ids = $this->taskFinder->getAllIds($src_project_id, array(Task::STATUS_OPEN, Task::STATUS_CLOSED));
+
+ foreach ($task_ids as $task_id) {
+ if (! $this->taskDuplication->duplicateToProject($task_id, $dst_project_id)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/app/Model/TaskAnalytic.php b/app/Model/TaskAnalytic.php
index bdfec3cb..cff56744 100644
--- a/app/Model/TaskAnalytic.php
+++ b/app/Model/TaskAnalytic.php
@@ -48,7 +48,7 @@ class TaskAnalytic extends Base
public function getTimeSpentByColumn(array $task)
{
$result = array();
- $columns = $this->board->getColumnsList($task['project_id']);
+ $columns = $this->column->getList($task['project_id']);
$sums = $this->transition->getTimeSpentByTask($task['id']);
foreach ($columns as $column_id => $column_title) {
diff --git a/app/Model/TaskCreation.php b/app/Model/TaskCreation.php
index 5ef1a04b..576eb18c 100644
--- a/app/Model/TaskCreation.php
+++ b/app/Model/TaskCreation.php
@@ -49,13 +49,14 @@ class TaskCreation extends Base
*/
public function prepare(array &$values)
{
- $this->dateParser->convert($values, array('date_due'));
- $this->dateParser->convert($values, array('date_started'), true);
+ $values = $this->dateParser->convert($values, array('date_due'));
+ $values = $this->dateParser->convert($values, array('date_started'), true);
+
$this->removeFields($values, array('another_task'));
$this->resetFields($values, array('date_started', 'creator_id', 'owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
if (empty($values['column_id'])) {
- $values['column_id'] = $this->board->getFirstColumn($values['project_id']);
+ $values['column_id'] = $this->column->getFirstColumnId($values['project_id']);
}
if (empty($values['color_id'])) {
@@ -86,8 +87,16 @@ class TaskCreation extends Base
*/
private function fireEvents($task_id, array $values)
{
- $values['task_id'] = $task_id;
- $this->container['dispatcher']->dispatch(Task::EVENT_CREATE_UPDATE, new TaskEvent($values));
- $this->container['dispatcher']->dispatch(Task::EVENT_CREATE, new TaskEvent($values));
+ $event = new TaskEvent(array('task_id' => $task_id) + $values);
+
+ $this->logger->debug('Event fired: '.Task::EVENT_CREATE_UPDATE);
+ $this->logger->debug('Event fired: '.Task::EVENT_CREATE);
+
+ $this->dispatcher->dispatch(Task::EVENT_CREATE_UPDATE, $event);
+ $this->dispatcher->dispatch(Task::EVENT_CREATE, $event);
+
+ if (! empty($values['description'])) {
+ $this->userMention->fireEvents($values['description'], Task::EVENT_USER_MENTION, $event);
+ }
}
}
diff --git a/app/Model/TaskDuplication.php b/app/Model/TaskDuplication.php
index e81fb232..b081aac1 100644
--- a/app/Model/TaskDuplication.php
+++ b/app/Model/TaskDuplication.php
@@ -64,7 +64,7 @@ class TaskDuplication extends Base
if ($values['recurrence_status'] == Task::RECURRING_STATUS_PENDING) {
$values['recurrence_parent'] = $task_id;
- $values['column_id'] = $this->board->getFirstColumn($values['project_id']);
+ $values['column_id'] = $this->column->getFirstColumnId($values['project_id']);
$this->calculateRecurringTaskDueDate($values);
$recurring_task_id = $this->save($task_id, $values);
@@ -181,12 +181,12 @@ class TaskDuplication extends Base
// Check if the column exists for the destination project
if ($values['column_id'] > 0) {
- $values['column_id'] = $this->board->getColumnIdByTitle(
+ $values['column_id'] = $this->column->getColumnIdByTitle(
$values['project_id'],
- $this->board->getColumnTitleById($values['column_id'])
+ $this->column->getColumnTitleById($values['column_id'])
);
- $values['column_id'] = $values['column_id'] ?: $this->board->getFirstColumn($values['project_id']);
+ $values['column_id'] = $values['column_id'] ?: $this->column->getFirstColumnId($values['project_id']);
}
return $values;
diff --git a/app/Model/TaskExport.php b/app/Model/TaskExport.php
index d38a5384..ed179a4f 100644
--- a/app/Model/TaskExport.php
+++ b/app/Model/TaskExport.php
@@ -58,6 +58,7 @@ class TaskExport extends Base
tasks.date_due,
creators.username AS creator_username,
users.username AS assignee_username,
+ users.name AS assignee_name,
tasks.score,
tasks.title,
tasks.date_creation,
@@ -105,7 +106,7 @@ class TaskExport extends Base
$task['score'] = $task['score'] ?: 0;
$task['swimlane_id'] = isset($swimlanes[$task['swimlane_id']]) ? $swimlanes[$task['swimlane_id']] : '?';
- $this->dateParser->format($task, array('date_due', 'date_modification', 'date_creation', 'date_started', 'date_completed'), 'Y-m-d');
+ $task = $this->dateParser->format($task, array('date_due', 'date_modification', 'date_creation', 'date_started', 'date_completed'), 'Y-m-d');
return $task;
}
@@ -129,7 +130,8 @@ class TaskExport extends Base
e('Color'),
e('Due date'),
e('Creator'),
- e('Assignee'),
+ e('Assignee Username'),
+ e('Assignee Name'),
e('Complexity'),
e('Title'),
e('Creation date'),
diff --git a/app/Model/TaskExternalLink.php b/app/Model/TaskExternalLink.php
new file mode 100644
index 00000000..f2c756b4
--- /dev/null
+++ b/app/Model/TaskExternalLink.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Task External Link Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class TaskExternalLink extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'task_has_external_links';
+
+ /**
+ * Get all links
+ *
+ * @access public
+ * @param integer $task_id
+ * @return array
+ */
+ public function getAll($task_id)
+ {
+ $types = $this->externalLinkManager->getTypes();
+
+ $links = $this->db->table(self::TABLE)
+ ->columns(self::TABLE.'.*', User::TABLE.'.name AS creator_name', User::TABLE.'.username AS creator_username')
+ ->eq('task_id', $task_id)
+ ->asc('title')
+ ->join(User::TABLE, 'id', 'creator_id')
+ ->findAll();
+
+ foreach ($links as &$link) {
+ $link['dependency_label'] = $this->externalLinkManager->getDependencyLabel($link['link_type'], $link['dependency']);
+ $link['type'] = isset($types[$link['link_type']]) ? $types[$link['link_type']] : t('Unknown');
+ }
+
+ return $links;
+ }
+
+ /**
+ * Get link
+ *
+ * @access public
+ * @param integer $link_id
+ * @return array
+ */
+ public function getById($link_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $link_id)->findOne();
+ }
+
+ /**
+ * Add a new link in the database
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean|integer
+ */
+ public function create(array $values)
+ {
+ unset($values['id']);
+ $values['creator_id'] = $this->userSession->getId();
+ $values['date_creation'] = time();
+ $values['date_modification'] = $values['date_creation'];
+
+ return $this->persist(self::TABLE, $values);
+ }
+
+ /**
+ * Modify external link
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ $values['date_modification'] = time();
+ return $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values);
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ * @param integer $link_id
+ * @return boolean
+ */
+ public function remove($link_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $link_id)->remove();
+ }
+}
diff --git a/app/Model/TaskFile.php b/app/Model/TaskFile.php
new file mode 100644
index 00000000..45a3b97f
--- /dev/null
+++ b/app/Model/TaskFile.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * Task File Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class TaskFile extends File
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'task_has_files';
+
+ /**
+ * SQL foreign key
+ *
+ * @var string
+ */
+ const FOREIGN_KEY = 'task_id';
+
+ /**
+ * Path prefix
+ *
+ * @var string
+ */
+ const PATH_PREFIX = 'tasks';
+
+ /**
+ * Events
+ *
+ * @var string
+ */
+ const EVENT_CREATE = 'task.file.create';
+
+ /**
+ * Handle screenshot upload
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param string $blob Base64 encoded image
+ * @return bool|integer
+ */
+ public function uploadScreenshot($task_id, $blob)
+ {
+ $original_filename = e('Screenshot taken %s', $this->helper->dt->datetime(time())).'.png';
+ return $this->uploadContent($task_id, $original_filename, $blob);
+ }
+}
diff --git a/app/Model/TaskFilter.php b/app/Model/TaskFilter.php
index 137a7a8e..1883298d 100644
--- a/app/Model/TaskFilter.php
+++ b/app/Model/TaskFilter.php
@@ -30,6 +30,7 @@ class TaskFilter extends Base
'T_COLUMN' => 'filterByColumnName',
'T_REFERENCE' => 'filterByReference',
'T_SWIMLANE' => 'filterBySwimlaneName',
+ 'T_LINK' => 'filterByLinkName',
);
/**
@@ -108,6 +109,22 @@ class TaskFilter extends Base
}
/**
+ * Create a new link query
+ *
+ * @access public
+ * @return \PicoDb\Table
+ */
+ public function createLinkQuery()
+ {
+ return $this->db->table(TaskLink::TABLE)
+ ->columns(
+ TaskLink::TABLE.'.task_id',
+ Link::TABLE.'.label'
+ )
+ ->join(Link::TABLE, 'id', 'link_id', TaskLink::TABLE);
+ }
+
+ /**
* Clone the filter
*
* @access public
@@ -452,7 +469,7 @@ class TaskFilter extends Base
$this->query->beginOr();
foreach ($values as $project) {
- $this->query->ilike(Board::TABLE.'.title', $project);
+ $this->query->ilike(Column::TABLE.'.title', $project);
}
$this->query->closeOr();
@@ -507,6 +524,30 @@ class TaskFilter extends Base
}
/**
+ * Filter by link
+ *
+ * @access public
+ * @param array $values List of links
+ * @return TaskFilter
+ */
+ public function filterByLinkName(array $values)
+ {
+ $this->query->beginOr();
+
+ $link_query = $this->createLinkQuery()->in(Link::TABLE.'.label', $values);
+ $matching_task_ids = $link_query->findAllByColumn('task_id');
+ if (empty($matching_task_ids)) {
+ $this->query->eq(Task::TABLE.'.id', 0);
+ } else {
+ $this->query->in(Task::TABLE.'.id', $matching_task_ids);
+ }
+
+ $this->query->closeOr();
+
+ return $this;
+ }
+
+ /**
* Filter by due date
*
* @access public
diff --git a/app/Model/TaskFinder.php b/app/Model/TaskFinder.php
index 9514fe4a..0492a9bf 100644
--- a/app/Model/TaskFinder.php
+++ b/app/Model/TaskFinder.php
@@ -38,14 +38,14 @@ class TaskFinder extends Base
Task::TABLE.'.time_spent',
Task::TABLE.'.time_estimated',
Project::TABLE.'.name AS project_name',
- Board::TABLE.'.title AS column_name',
+ Column::TABLE.'.title AS column_name',
User::TABLE.'.username AS assignee_username',
User::TABLE.'.name AS assignee_name'
)
->eq(Task::TABLE.'.is_active', $is_active)
->in(Project::TABLE.'.id', $project_ids)
->join(Project::TABLE, 'id', 'project_id')
- ->join(Board::TABLE, 'id', 'column_id', Task::TABLE)
+ ->join(Column::TABLE, 'id', 'column_id', Task::TABLE)
->join(User::TABLE, 'id', 'owner_id', Task::TABLE);
}
@@ -88,11 +88,12 @@ class TaskFinder extends Base
return $this->db
->table(Task::TABLE)
->columns(
- '(SELECT count(*) FROM '.Comment::TABLE.' WHERE task_id=tasks.id) AS nb_comments',
- '(SELECT count(*) FROM '.File::TABLE.' WHERE task_id=tasks.id) AS nb_files',
- '(SELECT count(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id) AS nb_subtasks',
- '(SELECT count(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id AND status=2) AS nb_completed_subtasks',
- '(SELECT count(*) FROM '.TaskLink::TABLE.' WHERE '.TaskLink::TABLE.'.task_id = tasks.id) AS nb_links',
+ '(SELECT COUNT(*) FROM '.Comment::TABLE.' WHERE task_id=tasks.id) AS nb_comments',
+ '(SELECT COUNT(*) FROM '.TaskFile::TABLE.' WHERE task_id=tasks.id) AS nb_files',
+ '(SELECT COUNT(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id) AS nb_subtasks',
+ '(SELECT COUNT(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id AND status=2) AS nb_completed_subtasks',
+ '(SELECT COUNT(*) FROM '.TaskLink::TABLE.' WHERE '.TaskLink::TABLE.'.task_id = tasks.id) AS nb_links',
+ '(SELECT COUNT(*) FROM '.TaskExternalLink::TABLE.' WHERE '.TaskExternalLink::TABLE.'.task_id = tasks.id) AS nb_external_links',
'(SELECT DISTINCT 1 FROM '.TaskLink::TABLE.' WHERE '.TaskLink::TABLE.'.task_id = tasks.id AND '.TaskLink::TABLE.'.link_id = 9) AS is_milestone',
'tasks.id',
'tasks.reference',
@@ -113,6 +114,7 @@ class TaskFinder extends Base
'tasks.is_active',
'tasks.score',
'tasks.category_id',
+ 'tasks.priority',
'tasks.date_moved',
'tasks.recurrence_status',
'tasks.recurrence_trigger',
@@ -122,19 +124,20 @@ class TaskFinder extends Base
'tasks.recurrence_parent',
'tasks.recurrence_child',
'tasks.time_estimated',
+ 'tasks.time_spent',
User::TABLE.'.username AS assignee_username',
User::TABLE.'.name AS assignee_name',
Category::TABLE.'.name AS category_name',
Category::TABLE.'.description AS category_description',
- Board::TABLE.'.title AS column_name',
- Board::TABLE.'.position AS column_position',
+ Column::TABLE.'.title AS column_name',
+ Column::TABLE.'.position AS column_position',
Swimlane::TABLE.'.name AS swimlane_name',
Project::TABLE.'.default_swimlane',
Project::TABLE.'.name AS project_name'
)
->join(User::TABLE, 'id', 'owner_id', Task::TABLE)
->join(Category::TABLE, 'id', 'category_id', Task::TABLE)
- ->join(Board::TABLE, 'id', 'column_id', Task::TABLE)
+ ->join(Column::TABLE, 'id', 'column_id', Task::TABLE)
->join(Swimlane::TABLE, 'id', 'swimlane_id', Task::TABLE)
->join(Project::TABLE, 'id', 'project_id', Task::TABLE);
}
@@ -177,6 +180,23 @@ class TaskFinder extends Base
}
/**
+ * Get all tasks for a given project and status
+ *
+ * @access public
+ * @param integer $project_id
+ * @param array $status
+ * @return array
+ */
+ public function getAllIds($project_id, array $status = array(Task::STATUS_OPEN))
+ {
+ return $this->db
+ ->table(Task::TABLE)
+ ->eq(Task::TABLE.'.project_id', $project_id)
+ ->in(Task::TABLE.'.is_active', $status)
+ ->findAllByColumn('id');
+ }
+
+ /**
* Get overdue tasks query
*
* @access public
@@ -307,6 +327,7 @@ class TaskFinder extends Base
tasks.is_active,
tasks.score,
tasks.category_id,
+ tasks.priority,
tasks.swimlane_id,
tasks.date_moved,
tasks.recurrence_status,
diff --git a/app/Model/TaskImport.php b/app/Model/TaskImport.php
index e8dd1946..ccab0152 100644
--- a/app/Model/TaskImport.php
+++ b/app/Model/TaskImport.php
@@ -111,7 +111,7 @@ class TaskImport extends Base
}
if (! empty($row['column'])) {
- $values['column_id'] = $this->board->getColumnIdByTitle($this->projectId, $row['column']);
+ $values['column_id'] = $this->column->getColumnIdByTitle($this->projectId, $row['column']);
}
if (! empty($row['category'])) {
diff --git a/app/Model/TaskLink.php b/app/Model/TaskLink.php
index 1ac59203..a57bf3b0 100644
--- a/app/Model/TaskLink.php
+++ b/app/Model/TaskLink.php
@@ -2,8 +2,6 @@
namespace Kanboard\Model;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
use Kanboard\Event\TaskLinkEvent;
/**
@@ -77,22 +75,23 @@ class TaskLink extends Base
Task::TABLE.'.title',
Task::TABLE.'.is_active',
Task::TABLE.'.project_id',
+ Task::TABLE.'.column_id',
Task::TABLE.'.time_spent AS task_time_spent',
Task::TABLE.'.time_estimated AS task_time_estimated',
Task::TABLE.'.owner_id AS task_assignee_id',
User::TABLE.'.username AS task_assignee_username',
User::TABLE.'.name AS task_assignee_name',
- Board::TABLE.'.title AS column_title',
+ Column::TABLE.'.title AS column_title',
Project::TABLE.'.name AS project_name'
)
->eq(self::TABLE.'.task_id', $task_id)
->join(Link::TABLE, 'id', 'link_id')
->join(Task::TABLE, 'id', 'opposite_task_id')
- ->join(Board::TABLE, 'id', 'column_id', Task::TABLE)
+ ->join(Column::TABLE, 'id', 'column_id', Task::TABLE)
->join(User::TABLE, 'id', 'owner_id', Task::TABLE)
->join(Project::TABLE, 'id', 'project_id', Task::TABLE)
->asc(Link::TABLE.'.id')
- ->desc(Board::TABLE.'.position')
+ ->desc(Column::TABLE.'.position')
->desc(Task::TABLE.'.is_active')
->asc(Task::TABLE.'.position')
->asc(Task::TABLE.'.id')
@@ -261,59 +260,4 @@ class TaskLink extends Base
return true;
}
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\Required('task_id', t('Field required')),
- new Validators\Required('opposite_task_id', t('Field required')),
- new Validators\Required('link_id', t('Field required')),
- new Validators\NotEquals('opposite_task_id', 'task_id', t('A task cannot be linked to itself')),
- new Validators\Exists('opposite_task_id', t('This linked task id doesn\'t exists'), $this->db->getConnection(), Task::TABLE, 'id')
- );
- }
-
- /**
- * Validate creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $v = new Validator($values, $this->commonValidationRules());
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('Field required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
}
diff --git a/app/Model/TaskModification.php b/app/Model/TaskModification.php
index 781646b8..8e59b3fe 100644
--- a/app/Model/TaskModification.php
+++ b/app/Model/TaskModification.php
@@ -17,16 +17,17 @@ class TaskModification extends Base
*
* @access public
* @param array $values
+ * @param boolean $fire_events
* @return boolean
*/
- public function update(array $values)
+ public function update(array $values, $fire_events = true)
{
$original_task = $this->taskFinder->getById($values['id']);
$this->prepare($values);
$result = $this->db->table(Task::TABLE)->eq('id', $original_task['id'])->update($values);
- if ($result) {
+ if ($fire_events && $result) {
$this->fireEvents($original_task, $values);
}
@@ -51,13 +52,14 @@ class TaskModification extends Base
if ($this->isFieldModified('owner_id', $event_data['changes'])) {
$events[] = Task::EVENT_ASSIGNEE_CHANGE;
- } else {
+ } elseif (! empty($event_data['changes'])) {
$events[] = Task::EVENT_CREATE_UPDATE;
$events[] = Task::EVENT_UPDATE;
}
foreach ($events as $event) {
- $this->container['dispatcher']->dispatch($event, new TaskEvent($event_data));
+ $this->logger->debug('Event fired: '.$event);
+ $this->dispatcher->dispatch($event, new TaskEvent($event_data));
}
}
@@ -82,11 +84,12 @@ class TaskModification extends Base
*/
public function prepare(array &$values)
{
- $this->dateParser->convert($values, array('date_due'));
- $this->dateParser->convert($values, array('date_started'), true);
+ $values = $this->dateParser->convert($values, array('date_due'));
+ $values = $this->dateParser->convert($values, array('date_started'), true);
+
$this->removeFields($values, array('another_task', 'id'));
$this->resetFields($values, array('date_due', 'date_started', 'score', 'category_id', 'time_estimated', 'time_spent'));
- $this->convertIntegerFields($values, array('is_active', 'recurrence_status', 'recurrence_trigger', 'recurrence_factor', 'recurrence_timeframe', 'recurrence_basedate'));
+ $this->convertIntegerFields($values, array('priority', 'is_active', 'recurrence_status', 'recurrence_trigger', 'recurrence_factor', 'recurrence_timeframe', 'recurrence_basedate'));
$values['date_modification'] = time();
}
diff --git a/app/Model/TaskPermission.php b/app/Model/TaskPermission.php
index 4bbe6d1d..fac2153e 100644
--- a/app/Model/TaskPermission.php
+++ b/app/Model/TaskPermission.php
@@ -2,6 +2,8 @@
namespace Kanboard\Model;
+use Kanboard\Core\Security\Role;
+
/**
* Task permission model
*
@@ -20,7 +22,7 @@ class TaskPermission extends Base
*/
public function canRemoveTask(array $task)
{
- if ($this->userSession->isAdmin() || $this->projectPermission->isManager($task['project_id'], $this->userSession->getId())) {
+ if ($this->userSession->isAdmin() || $this->projectUserRole->getUserRole($task['project_id'], $this->userSession->getId()) === Role::PROJECT_MANAGER) {
return true;
} elseif (isset($task['creator_id']) && $task['creator_id'] == $this->userSession->getId()) {
return true;
diff --git a/app/Model/TaskPosition.php b/app/Model/TaskPosition.php
index da363cb3..4c9928d7 100644
--- a/app/Model/TaskPosition.php
+++ b/app/Model/TaskPosition.php
@@ -32,7 +32,6 @@ class TaskPosition extends Base
$task = $this->taskFinder->getById($task_id);
- // Ignore closed tasks
if ($task['is_active'] == Task::STATUS_CLOSED) {
return true;
}
@@ -167,7 +166,12 @@ class TaskPosition extends Base
return false;
}
- return true;
+ $now = time();
+
+ return $this->db->table(Task::TABLE)->eq('id', $task_id)->update(array(
+ 'date_moved' => $now,
+ 'date_modification' => $now,
+ ));
}
/**
@@ -221,11 +225,14 @@ class TaskPosition extends Base
);
if ($task['swimlane_id'] != $new_swimlane_id) {
- $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_SWIMLANE, new TaskEvent($event_data));
+ $this->logger->debug('Event fired: '.Task::EVENT_MOVE_SWIMLANE);
+ $this->dispatcher->dispatch(Task::EVENT_MOVE_SWIMLANE, new TaskEvent($event_data));
} elseif ($task['column_id'] != $new_column_id) {
- $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_COLUMN, new TaskEvent($event_data));
+ $this->logger->debug('Event fired: '.Task::EVENT_MOVE_COLUMN);
+ $this->dispatcher->dispatch(Task::EVENT_MOVE_COLUMN, new TaskEvent($event_data));
} elseif ($task['position'] != $new_position) {
- $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data));
+ $this->logger->debug('Event fired: '.Task::EVENT_MOVE_POSITION);
+ $this->dispatcher->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data));
}
}
}
diff --git a/app/Model/TaskStatus.php b/app/Model/TaskStatus.php
index a5199ed9..2b902815 100644
--- a/app/Model/TaskStatus.php
+++ b/app/Model/TaskStatus.php
@@ -62,6 +62,32 @@ class TaskStatus extends Base
}
/**
+ * Close multiple tasks
+ *
+ * @access public
+ * @param array $task_ids
+ */
+ public function closeMultipleTasks(array $task_ids)
+ {
+ foreach ($task_ids as $task_id) {
+ $this->close($task_id);
+ }
+ }
+
+ /**
+ * Close all tasks within a column/swimlane
+ *
+ * @access public
+ * @param integer $swimlane_id
+ * @param integer $column_id
+ */
+ public function closeTasksBySwimlaneAndColumn($swimlane_id, $column_id)
+ {
+ $task_ids = $this->db->table(Task::TABLE)->eq('swimlane_id', $swimlane_id)->eq('column_id', $column_id)->findAllByColumn('id');
+ $this->closeMultipleTasks($task_ids);
+ }
+
+ /**
* Common method to change the status of task
*
* @access private
@@ -87,10 +113,8 @@ class TaskStatus extends Base
));
if ($result) {
- $this->container['dispatcher']->dispatch(
- $event,
- new TaskEvent(array('task_id' => $task_id) + $this->taskFinder->getById($task_id))
- );
+ $this->logger->debug('Event fired: '.$event);
+ $this->dispatcher->dispatch($event, new TaskEvent(array('task_id' => $task_id) + $this->taskFinder->getById($task_id)));
}
return $result;
diff --git a/app/Model/Transition.php b/app/Model/Transition.php
index b1f8f678..aa76d58f 100644
--- a/app/Model/Transition.php
+++ b/app/Model/Transition.php
@@ -76,8 +76,8 @@ class Transition extends Base
->eq('task_id', $task_id)
->desc('date')
->join(User::TABLE, 'id', 'user_id')
- ->join(Board::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src')
- ->join(Board::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst')
+ ->join(Column::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src')
+ ->join(Column::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst')
->findAll();
}
@@ -118,8 +118,8 @@ class Transition extends Base
->desc('date')
->join(Task::TABLE, 'id', 'task_id')
->join(User::TABLE, 'id', 'user_id')
- ->join(Board::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src')
- ->join(Board::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst')
+ ->join(Column::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src')
+ ->join(Column::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst')
->findAll();
}
diff --git a/app/Model/User.php b/app/Model/User.php
index 6e7e94e0..2d87d35b 100644
--- a/app/Model/User.php
+++ b/app/Model/User.php
@@ -3,10 +3,8 @@
namespace Kanboard\Model;
use PicoDb\Database;
-use SimpleValidator\Validator;
-use SimpleValidator\Validators;
-use Kanboard\Core\Session;
-use Kanboard\Core\Security;
+use Kanboard\Core\Security\Token;
+use Kanboard\Core\Security\Role;
/**
* User model
@@ -43,6 +41,18 @@ class User extends Base
}
/**
+ * Return true if the user is active
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return boolean
+ */
+ public function isActive($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $user_id)->eq('is_active', 1)->exists();
+ }
+
+ /**
* Get query to fetch all users
*
* @access public
@@ -50,21 +60,7 @@ class User extends Base
*/
public function getQuery()
{
- return $this->db
- ->table(self::TABLE)
- ->columns(
- 'id',
- 'username',
- 'name',
- 'email',
- 'is_admin',
- 'is_project_admin',
- 'is_ldap_user',
- 'notifications_enabled',
- 'google_id',
- 'github_id',
- 'twofactor_activated'
- );
+ return $this->db->table(self::TABLE);
}
/**
@@ -91,7 +87,7 @@ class User extends Base
$this->db
->table(User::TABLE)
->eq('id', $user_id)
- ->eq('is_admin', 1)
+ ->eq('role', Role::APP_ADMIN)
->exists();
}
@@ -111,48 +107,17 @@ class User extends Base
* Get a specific user by the Google id
*
* @access public
- * @param string $google_id Google unique id
+ * @param string $column
+ * @param string $id
* @return array|boolean
*/
- public function getByGoogleId($google_id)
+ public function getByExternalId($column, $id)
{
- if (empty($google_id)) {
+ if (empty($id)) {
return false;
}
- return $this->db->table(self::TABLE)->eq('google_id', $google_id)->findOne();
- }
-
- /**
- * Get a specific user by the Github id
- *
- * @access public
- * @param string $github_id Github user id
- * @return array|boolean
- */
- public function getByGithubId($github_id)
- {
- if (empty($github_id)) {
- return false;
- }
-
- return $this->db->table(self::TABLE)->eq('github_id', $github_id)->findOne();
- }
-
- /**
- * Get a specific user by the Gitlab id
- *
- * @access public
- * @param string $gitlab_id Gitlab user id
- * @return array|boolean
- */
- public function getByGitlabId($gitlab_id)
- {
- if (empty($gitlab_id)) {
- return false;
- }
-
- return $this->db->table(self::TABLE)->eq('gitlab_id', $gitlab_id)->findOne();
+ return $this->db->table(self::TABLE)->eq($column, $id)->findOne();
}
/**
@@ -172,7 +137,7 @@ class User extends Base
*
* @access public
* @param string $username Username
- * @return array
+ * @return integer
*/
public function getIdByUsername($username)
{
@@ -240,9 +205,9 @@ class User extends Base
* @param boolean $prepend Prepend "All users"
* @return array
*/
- public function getList($prepend = false)
+ public function getActiveUsersList($prepend = false)
{
- $users = $this->db->table(self::TABLE)->columns('id', 'username', 'name')->findAll();
+ $users = $this->db->table(self::TABLE)->eq('is_active', 1)->columns('id', 'username', 'name')->findAll();
$listing = $this->prepareList($users);
if ($prepend) {
@@ -289,7 +254,7 @@ class User extends Base
}
$this->removeFields($values, array('confirmation', 'current_password'));
- $this->resetFields($values, array('is_admin', 'is_ldap_user', 'is_project_admin', 'disable_login_form'));
+ $this->resetFields($values, array('is_ldap_user', 'disable_login_form'));
$this->convertNullFields($values, array('gitlab_id'));
$this->convertIntegerFields($values, array('gitlab_id'));
}
@@ -312,7 +277,7 @@ class User extends Base
*
* @access public
* @param array $values Form values
- * @return array
+ * @return boolean
*/
public function update(array $values)
{
@@ -320,14 +285,38 @@ class User extends Base
$result = $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values);
// If the user is connected refresh his session
- if (Session::isOpen() && $this->userSession->getId() == $values['id']) {
- $this->userSession->refresh();
+ if ($this->userSession->getId() == $values['id']) {
+ $this->userSession->initialize($this->getById($this->userSession->getId()));
}
return $result;
}
/**
+ * Disable a specific user
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function disable($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $user_id)->update(array('is_active' => 0));
+ }
+
+ /**
+ * Enable a specific user
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function enable($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $user_id)->update(array('is_active' => 1));
+ }
+
+ /**
* Remove a specific user
*
* @access public
@@ -355,10 +344,10 @@ class User extends Base
// All private projects are removed
$project_ids = $db->table(Project::TABLE)
- ->eq('is_private', 1)
- ->eq(ProjectPermission::TABLE.'.user_id', $user_id)
- ->join(ProjectPermission::TABLE, 'project_id', 'id')
- ->findAllByColumn(Project::TABLE.'.id');
+ ->eq('is_private', 1)
+ ->eq(ProjectUserRole::TABLE.'.user_id', $user_id)
+ ->join(ProjectUserRole::TABLE, 'project_id', 'id')
+ ->findAllByColumn(Project::TABLE.'.id');
if (! empty($project_ids)) {
$db->table(Project::TABLE)->in('id', $project_ids)->remove();
@@ -383,7 +372,7 @@ class User extends Base
return $this->db
->table(self::TABLE)
->eq('id', $user_id)
- ->save(array('token' => Security::generateToken()));
+ ->save(array('token' => Token::getToken()));
}
/**
@@ -400,200 +389,4 @@ class User extends Base
->eq('id', $user_id)
->save(array('token' => ''));
}
-
- /**
- * Get the number of failed login for the user
- *
- * @access public
- * @param string $username
- * @return integer
- */
- public function getFailedLogin($username)
- {
- return (int) $this->db->table(self::TABLE)->eq('username', $username)->findOneColumn('nb_failed_login');
- }
-
- /**
- * Reset to 0 the counter of failed login
- *
- * @access public
- * @param string $username
- * @return boolean
- */
- public function resetFailedLogin($username)
- {
- return $this->db->table(self::TABLE)->eq('username', $username)->update(array('nb_failed_login' => 0, 'lock_expiration_date' => 0));
- }
-
- /**
- * Increment failed login counter
- *
- * @access public
- * @param string $username
- * @return boolean
- */
- public function incrementFailedLogin($username)
- {
- return $this->db->execute('UPDATE '.self::TABLE.' SET nb_failed_login=nb_failed_login+1 WHERE username=?', array($username)) !== false;
- }
-
- /**
- * Check if the account is locked
- *
- * @access public
- * @param string $username
- * @return boolean
- */
- public function isLocked($username)
- {
- return $this->db->table(self::TABLE)
- ->eq('username', $username)
- ->neq('lock_expiration_date', 0)
- ->gte('lock_expiration_date', time())
- ->exists();
- }
-
- /**
- * Lock the account for the specified duration
- *
- * @access public
- * @param string $username Username
- * @param integer $duration Duration in minutes
- * @return boolean
- */
- public function lock($username, $duration = 15)
- {
- return $this->db->table(self::TABLE)->eq('username', $username)->update(array('lock_expiration_date' => time() + $duration * 60));
- }
-
- /**
- * Common validation rules
- *
- * @access private
- * @return array
- */
- private function commonValidationRules()
- {
- return array(
- new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
- new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), self::TABLE, 'id'),
- new Validators\Email('email', t('Email address invalid')),
- new Validators\Integer('is_admin', t('This value must be an integer')),
- new Validators\Integer('is_project_admin', t('This value must be an integer')),
- new Validators\Integer('is_ldap_user', t('This value must be an integer')),
- );
- }
-
- /**
- * Common password validation rules
- *
- * @access private
- * @return array
- */
- private function commonPasswordValidationRules()
- {
- return array(
- new Validators\Required('password', t('The password is required')),
- new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6),
- new Validators\Required('confirmation', t('The confirmation is required')),
- new Validators\Equals('password', 'confirmation', t('Passwords don\'t match')),
- );
- }
-
- /**
- * Validate user creation
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateCreation(array $values)
- {
- $rules = array(
- new Validators\Required('username', t('The username is required')),
- );
-
- if (isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1) {
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
- } else {
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules(), $this->commonPasswordValidationRules()));
- }
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate user modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The user id is required')),
- new Validators\Required('username', t('The username is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate user API modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validateApiModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The user id is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
-
- return array(
- $v->execute(),
- $v->getErrors()
- );
- }
-
- /**
- * Validate password modification
- *
- * @access public
- * @param array $values Form values
- * @return array $valid, $errors [0] = Success or not, [1] = List of errors
- */
- public function validatePasswordModification(array $values)
- {
- $rules = array(
- new Validators\Required('id', t('The user id is required')),
- new Validators\Required('current_password', t('The current password is required')),
- );
-
- $v = new Validator($values, array_merge($rules, $this->commonPasswordValidationRules()));
-
- if ($v->execute()) {
-
- // Check password
- if ($this->authentication->authenticate($this->session['user']['username'], $values['current_password'])) {
- return array(true, array());
- } else {
- return array(false, array('current_password' => array(t('Wrong password'))));
- }
- }
-
- return array(false, $v->getErrors());
- }
}
diff --git a/app/Model/UserFilter.php b/app/Model/UserFilter.php
new file mode 100644
index 00000000..ff546e96
--- /dev/null
+++ b/app/Model/UserFilter.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * User Filter
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class UserFilter extends Base
+{
+ /**
+ * Search query
+ *
+ * @access private
+ * @var string
+ */
+ private $input;
+
+ /**
+ * Query
+ *
+ * @access protected
+ * @var \PicoDb\Table
+ */
+ protected $query;
+
+ /**
+ * Initialize filter
+ *
+ * @access public
+ * @param string $input
+ * @return UserFilter
+ */
+ public function create($input)
+ {
+ $this->query = $this->db->table(User::TABLE);
+ $this->input = $input;
+ return $this;
+ }
+
+ /**
+ * Filter users by name or username
+ *
+ * @access public
+ * @return UserFilter
+ */
+ public function filterByUsernameOrByName()
+ {
+ $this->query->beginOr()
+ ->ilike('username', '%'.$this->input.'%')
+ ->ilike('name', '%'.$this->input.'%')
+ ->closeOr();
+
+ return $this;
+ }
+
+ /**
+ * Get all results of the filter
+ *
+ * @access public
+ * @return array
+ */
+ public function findAll()
+ {
+ return $this->query->findAll();
+ }
+
+ /**
+ * Get the PicoDb query
+ *
+ * @access public
+ * @return \PicoDb\Table
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+}
diff --git a/app/Model/UserImport.php b/app/Model/UserImport.php
index 3c9e7a57..0ec4e802 100644
--- a/app/Model/UserImport.php
+++ b/app/Model/UserImport.php
@@ -4,6 +4,7 @@ namespace Kanboard\Model;
use SimpleValidator\Validator;
use SimpleValidator\Validators;
+use Kanboard\Core\Security\Role;
use Kanboard\Core\Csv;
/**
@@ -36,7 +37,7 @@ class UserImport extends Base
'email' => 'Email',
'name' => 'Full Name',
'is_admin' => 'Administrator',
- 'is_project_admin' => 'Project Administrator',
+ 'is_manager' => 'Manager',
'is_ldap_user' => 'Remote User',
);
}
@@ -75,10 +76,21 @@ class UserImport extends Base
{
$row['username'] = strtolower($row['username']);
- foreach (array('is_admin', 'is_project_admin', 'is_ldap_user') as $field) {
+ foreach (array('is_admin', 'is_manager', 'is_ldap_user') as $field) {
$row[$field] = Csv::getBooleanValue($row[$field]);
}
+ if ($row['is_admin'] == 1) {
+ $row['role'] = Role::APP_ADMIN;
+ } elseif ($row['is_manager'] == 1) {
+ $row['role'] = Role::APP_MANAGER;
+ } else {
+ $row['role'] = Role::APP_USER;
+ }
+
+ unset($row['is_admin']);
+ unset($row['is_manager']);
+
$this->removeEmptyFields($row, array('password', 'email', 'name'));
return $row;
@@ -98,8 +110,6 @@ class UserImport extends Base
new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), User::TABLE, 'id'),
new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6),
new Validators\Email('email', t('Email address invalid')),
- new Validators\Integer('is_admin', t('This value must be an integer')),
- new Validators\Integer('is_project_admin', t('This value must be an integer')),
new Validators\Integer('is_ldap_user', t('This value must be an integer')),
));
diff --git a/app/Model/UserLocking.php b/app/Model/UserLocking.php
new file mode 100644
index 00000000..67e4c244
--- /dev/null
+++ b/app/Model/UserLocking.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Kanboard\Model;
+
+/**
+ * User Locking Model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class UserLocking extends Base
+{
+ /**
+ * Get the number of failed login for the user
+ *
+ * @access public
+ * @param string $username
+ * @return integer
+ */
+ public function getFailedLogin($username)
+ {
+ return (int) $this->db->table(User::TABLE)
+ ->eq('username', $username)
+ ->findOneColumn('nb_failed_login');
+ }
+
+ /**
+ * Reset to 0 the counter of failed login
+ *
+ * @access public
+ * @param string $username
+ * @return boolean
+ */
+ public function resetFailedLogin($username)
+ {
+ return $this->db->table(User::TABLE)
+ ->eq('username', $username)
+ ->update(array(
+ 'nb_failed_login' => 0,
+ 'lock_expiration_date' => 0,
+ ));
+ }
+
+ /**
+ * Increment failed login counter
+ *
+ * @access public
+ * @param string $username
+ * @return boolean
+ */
+ public function incrementFailedLogin($username)
+ {
+ return $this->db->table(User::TABLE)
+ ->eq('username', $username)
+ ->increment('nb_failed_login', 1);
+ }
+
+ /**
+ * Check if the account is locked
+ *
+ * @access public
+ * @param string $username
+ * @return boolean
+ */
+ public function isLocked($username)
+ {
+ return $this->db->table(User::TABLE)
+ ->eq('username', $username)
+ ->neq('lock_expiration_date', 0)
+ ->gte('lock_expiration_date', time())
+ ->exists();
+ }
+
+ /**
+ * Lock the account for the specified duration
+ *
+ * @access public
+ * @param string $username Username
+ * @param integer $duration Duration in minutes
+ * @return boolean
+ */
+ public function lock($username, $duration = 15)
+ {
+ return $this->db->table(User::TABLE)
+ ->eq('username', $username)
+ ->update(array(
+ 'lock_expiration_date' => time() + $duration * 60
+ ));
+ }
+
+ /**
+ * Return true if the captcha must be shown
+ *
+ * @access public
+ * @param string $username
+ * @param integer $tries
+ * @return boolean
+ */
+ public function hasCaptcha($username, $tries = BRUTEFORCE_CAPTCHA)
+ {
+ return $this->getFailedLogin($username) >= $tries;
+ }
+}
diff --git a/app/Model/UserMention.php b/app/Model/UserMention.php
new file mode 100644
index 00000000..97a4e419
--- /dev/null
+++ b/app/Model/UserMention.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Kanboard\Model;
+
+use Kanboard\Event\GenericEvent;
+
+/**
+ * User Mention
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class UserMention extends Base
+{
+ /**
+ * Get list of mentioned users
+ *
+ * @access public
+ * @param string $content
+ * @return array
+ */
+ public function getMentionedUsers($content)
+ {
+ $users = array();
+
+ if (preg_match_all('/@([^\s]+)/', $content, $matches)) {
+ $users = $this->db->table(User::TABLE)
+ ->columns('id', 'username', 'name', 'email', 'language')
+ ->eq('notifications_enabled', 1)
+ ->neq('id', $this->userSession->getId())
+ ->in('username', array_unique($matches[1]))
+ ->findAll();
+ }
+
+ return $users;
+ }
+
+ /**
+ * Fire events for user mentions
+ *
+ * @access public
+ * @param string $content
+ * @param string $eventName
+ * @param GenericEvent $event
+ */
+ public function fireEvents($content, $eventName, GenericEvent $event)
+ {
+ if (empty($event['project_id'])) {
+ $event['project_id'] = $this->taskFinder->getProjectId($event['task_id']);
+ }
+
+ $users = $this->getMentionedUsers($content);
+
+ foreach ($users as $user) {
+ if ($this->projectPermission->isMember($event['project_id'], $user['id'])) {
+ $event['mention'] = $user;
+ $this->dispatcher->dispatch($eventName, $event);
+ }
+ }
+ }
+}
diff --git a/app/Model/UserNotification.php b/app/Model/UserNotification.php
index 3d98ebe9..e8a967ac 100644
--- a/app/Model/UserNotification.php
+++ b/app/Model/UserNotification.php
@@ -21,18 +21,12 @@ class UserNotification extends Base
*/
public function sendNotifications($event_name, array $event_data)
{
- $logged_user_id = $this->userSession->isLogged() ? $this->userSession->getId() : 0;
- $users = $this->getUsersWithNotificationEnabled($event_data['task']['project_id'], $logged_user_id);
-
- if (! empty($users)) {
- foreach ($users as $user) {
- if ($this->userNotificationFilter->shouldReceiveNotification($user, $event_data)) {
- $this->sendUserNotification($user, $event_name, $event_data);
- }
- }
+ $users = $this->getUsersWithNotificationEnabled($event_data['task']['project_id'], $this->userSession->getId());
- // Restore locales
- $this->config->setupTranslations();
+ foreach ($users as $user) {
+ if ($this->userNotificationFilter->shouldReceiveNotification($user, $event_data)) {
+ $this->sendUserNotification($user, $event_name, $event_data);
+ }
}
}
@@ -58,6 +52,9 @@ class UserNotification extends Base
foreach ($this->userNotificationType->getSelectedTypes($user['id']) as $type) {
$this->userNotificationType->getType($type)->notifyUser($user, $event_name, $event_data);
}
+
+ // Restore locales
+ $this->config->setupTranslations();
}
/**
@@ -74,7 +71,17 @@ class UserNotification extends Base
return $this->getEverybodyWithNotificationEnabled($exclude_user_id);
}
- return $this->getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id);
+ $users = array();
+ $members = $this->getProjectUserMembersWithNotificationEnabled($project_id, $exclude_user_id);
+ $groups = $this->getProjectGroupMembersWithNotificationEnabled($project_id, $exclude_user_id);
+
+ foreach (array_merge($members, $groups) as $user) {
+ if (! isset($users[$user['id']])) {
+ $users[$user['id']] = $user;
+ }
+ }
+
+ return array_values($users);
}
/**
@@ -145,17 +152,17 @@ class UserNotification extends Base
}
/**
- * Get a list of project members with notification enabled
+ * Get a list of group members with notification enabled
*
* @access private
* @param integer $project_id Project id
* @param integer $exclude_user_id User id to exclude
* @return array
*/
- private function getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id)
+ private function getProjectUserMembersWithNotificationEnabled($project_id, $exclude_user_id)
{
return $this->db
- ->table(ProjectPermission::TABLE)
+ ->table(ProjectUserRole::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)
@@ -164,6 +171,19 @@ class UserNotification extends Base
->findAll();
}
+ private function getProjectGroupMembersWithNotificationEnabled($project_id, $exclude_user_id)
+ {
+ return $this->db
+ ->table(ProjectGroupRole::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter')
+ ->join(GroupMember::TABLE, 'group_id', 'group_id', ProjectGroupRole::TABLE)
+ ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE)
+ ->eq(ProjectGroupRole::TABLE.'.project_id', $project_id)
+ ->eq(User::TABLE.'.notifications_enabled', '1')
+ ->neq(User::TABLE.'.id', $exclude_user_id)
+ ->findAll();
+ }
+
/**
* Get a list of project members with notification enabled
*
diff --git a/app/Model/UserSession.php b/app/Model/UserSession.php
deleted file mode 100644
index 1778114e..00000000
--- a/app/Model/UserSession.php
+++ /dev/null
@@ -1,177 +0,0 @@
-<?php
-
-namespace Kanboard\Model;
-
-/**
- * User Session
- *
- * @package model
- * @author Frederic Guillot
- */
-class UserSession extends Base
-{
- /**
- * Update user session information
- *
- * @access public
- * @param array $user User data
- */
- public function refresh(array $user = array())
- {
- if (empty($user)) {
- $user = $this->user->getById($this->userSession->getId());
- }
-
- if (isset($user['password'])) {
- unset($user['password']);
- }
-
- if (isset($user['twofactor_secret'])) {
- unset($user['twofactor_secret']);
- }
-
- $user['id'] = (int) $user['id'];
- $user['is_admin'] = (bool) $user['is_admin'];
- $user['is_project_admin'] = (bool) $user['is_project_admin'];
- $user['is_ldap_user'] = (bool) $user['is_ldap_user'];
- $user['twofactor_activated'] = (bool) $user['twofactor_activated'];
-
- $this->session['user'] = $user;
- }
-
- /**
- * Return true if the user has validated the 2FA key
- *
- * @access public
- * @return bool
- */
- public function check2FA()
- {
- return isset($this->session['2fa_validated']) && $this->session['2fa_validated'] === true;
- }
-
- /**
- * Return true if the user has 2FA enabled
- *
- * @access public
- * @return bool
- */
- public function has2FA()
- {
- return isset($this->session['user']['twofactor_activated']) && $this->session['user']['twofactor_activated'] === true;
- }
-
- /**
- * Return true if the logged user is admin
- *
- * @access public
- * @return bool
- */
- public function isAdmin()
- {
- return isset($this->session['user']['is_admin']) && $this->session['user']['is_admin'] === true;
- }
-
- /**
- * Return true if the logged user is project admin
- *
- * @access public
- * @return bool
- */
- public function isProjectAdmin()
- {
- return isset($this->session['user']['is_project_admin']) && $this->session['user']['is_project_admin'] === true;
- }
-
- /**
- * Get the connected user id
- *
- * @access public
- * @return integer
- */
- public function getId()
- {
- return isset($this->session['user']['id']) ? (int) $this->session['user']['id'] : 0;
- }
-
- /**
- * Check is the user is connected
- *
- * @access public
- * @return bool
- */
- public function isLogged()
- {
- return ! empty($this->session['user']);
- }
-
- /**
- * Get project filters from the session
- *
- * @access public
- * @param integer $project_id
- * @return string
- */
- public function getFilters($project_id)
- {
- return ! empty($_SESSION['filters'][$project_id]) ? $_SESSION['filters'][$project_id] : 'status:open';
- }
-
- /**
- * Save project filters in the session
- *
- * @access public
- * @param integer $project_id
- * @param string $filters
- */
- public function setFilters($project_id, $filters)
- {
- $_SESSION['filters'][$project_id] = $filters;
- }
-
- /**
- * Is board collapsed or expanded
- *
- * @access public
- * @param integer $project_id
- * @return boolean
- */
- public function isBoardCollapsed($project_id)
- {
- return ! empty($_SESSION['board_collapsed'][$project_id]) ? $_SESSION['board_collapsed'][$project_id] : false;
- }
-
- /**
- * Set board display mode
- *
- * @access public
- * @param integer $project_id
- * @param boolean $collapsed
- */
- public function setBoardDisplayMode($project_id, $collapsed)
- {
- $_SESSION['board_collapsed'][$project_id] = $collapsed;
- }
-
- /**
- * Set comments sorting
- *
- * @access public
- * @param string $order
- */
- public function setCommentSorting($order)
- {
- $this->session['comment_sorting'] = $order;
- }
-
- /**
- * Get comments sorting direction
- *
- * @access public
- * @return string
- */
- public function getCommentSorting()
- {
- return $this->session['comment_sorting'] ?: 'ASC';
- }
-}
diff --git a/app/Notification/Mail.php b/app/Notification/Mail.php
index cd8af852..c924fb50 100644
--- a/app/Notification/Mail.php
+++ b/app/Notification/Mail.php
@@ -4,7 +4,7 @@ namespace Kanboard\Notification;
use Kanboard\Core\Base;
use Kanboard\Model\Task;
-use Kanboard\Model\File;
+use Kanboard\Model\TaskFile;
use Kanboard\Model\Comment;
use Kanboard\Model\Subtask;
@@ -17,6 +17,13 @@ use Kanboard\Model\Subtask;
class Mail extends Base implements NotificationInterface
{
/**
+ * Notification type
+ *
+ * @var string
+ */
+ const TYPE = 'email';
+
+ /**
* Send notification to a user
*
* @access public
@@ -75,7 +82,7 @@ class Mail extends Base implements NotificationInterface
public function getMailSubject($event_name, array $event_data)
{
switch ($event_name) {
- case File::EVENT_CREATE:
+ case TaskFile::EVENT_CREATE:
$subject = $this->getStandardMailSubject(e('New attachment'), $event_data);
break;
case Comment::EVENT_CREATE:
@@ -114,6 +121,10 @@ class Mail extends Base implements NotificationInterface
case Task::EVENT_ASSIGNEE_CHANGE:
$subject = $this->getStandardMailSubject(e('Assignee change'), $event_data);
break;
+ case Task::EVENT_USER_MENTION:
+ case Comment::EVENT_USER_MENTION:
+ $subject = $this->getStandardMailSubject(e('Mentioned'), $event_data);
+ break;
case Task::EVENT_OVERDUE:
$subject = e('[%s] Overdue tasks', $event_data['project_name']);
break;
diff --git a/app/Notification/Web.php b/app/Notification/Web.php
index 989ae06e..9271c193 100644
--- a/app/Notification/Web.php
+++ b/app/Notification/Web.php
@@ -13,6 +13,13 @@ use Kanboard\Core\Base;
class Web extends Base implements NotificationInterface
{
/**
+ * Notification type
+ *
+ * @var string
+ */
+ const TYPE = 'web';
+
+ /**
* Send notification to a user
*
* @access public
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index a021c1cc..9a551e99 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -3,9 +3,209 @@
namespace Schema;
use PDO;
-use Kanboard\Core\Security;
+use Kanboard\Core\Security\Token;
+use Kanboard\Core\Security\Role;
-const VERSION = 93;
+const VERSION = 107;
+
+function version_107(PDO $pdo)
+{
+ $pdo->exec("UPDATE project_activities SET event_name='task.file.create' WHERE event_name='file.create'");
+}
+
+function version_106(PDO $pdo)
+{
+ $pdo->exec('RENAME TABLE files TO task_has_files');
+
+ $pdo->exec("
+ CREATE TABLE project_has_files (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `project_id` INT NOT NULL,
+ `name` VARCHAR(255) NOT NULL,
+ `path` VARCHAR(255) NOT NULL,
+ `is_image` TINYINT(1) DEFAULT 0,
+ `size` INT DEFAULT 0 NOT NULL,
+ `user_id` INT DEFAULT 0 NOT NULL,
+ `date` INT DEFAULT 0 NOT NULL,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ PRIMARY KEY(id)
+ ) ENGINE=InnoDB CHARSET=utf8"
+ );
+}
+
+function version_105(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN is_active TINYINT(1) DEFAULT 1");
+}
+
+function version_104(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_external_links (
+ id INT NOT NULL AUTO_INCREMENT,
+ link_type VARCHAR(100) NOT NULL,
+ dependency VARCHAR(100) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ url VARCHAR(255) NOT NULL,
+ date_creation INT NOT NULL,
+ date_modification INT NOT NULL,
+ task_id INT NOT NULL,
+ creator_id INT DEFAULT 0,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ PRIMARY KEY(id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+}
+
+function version_103(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_default INT DEFAULT 0");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_start INT DEFAULT 0");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_end INT DEFAULT 3");
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN priority INT DEFAULT 0");
+}
+
+function version_102(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN owner_id INT DEFAULT 0");
+}
+
+function version_101(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE password_reset (
+ token VARCHAR(80) PRIMARY KEY,
+ user_id INT NOT NULL,
+ date_expiration INT NOT NULL,
+ date_creation INT NOT NULL,
+ ip VARCHAR(45) NOT NULL,
+ user_agent VARCHAR(255) NOT NULL,
+ is_active TINYINT(1) NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("INSERT INTO settings VALUES ('password_reset', '1')");
+}
+
+function version_100(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE `actions` MODIFY `action_name` VARCHAR(255)');
+}
+
+function version_99(PDO $pdo)
+{
+ $rq = $pdo->prepare('SELECT * FROM actions');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE actions SET action_name=? WHERE id=?');
+
+ foreach ($rows as $row) {
+ if ($row['action_name'] === 'TaskAssignCurrentUser' && $row['event_name'] === 'task.move.column') {
+ $row['action_name'] = '\Kanboard\Action\TaskAssignCurrentUserColumn';
+ } elseif ($row['action_name'] === 'TaskClose' && $row['event_name'] === 'task.move.column') {
+ $row['action_name'] = '\Kanboard\Action\TaskCloseColumn';
+ } elseif ($row['action_name'] === 'TaskLogMoveAnotherColumn') {
+ $row['action_name'] = '\Kanboard\Action\CommentCreationMoveTaskColumn';
+ } elseif ($row['action_name']{0} !== '\\') {
+ $row['action_name'] = '\Kanboard\Action\\'.$row['action_name'];
+ }
+
+ $rq->execute(array($row['action_name'], $row['id']));
+ }
+}
+
+function version_98(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE `users` MODIFY `language` VARCHAR(5)');
+}
+
+function version_97(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE `users` ADD COLUMN `role` VARCHAR(25) NOT NULL DEFAULT '".Role::APP_USER."'");
+
+ $rq = $pdo->prepare('SELECT * FROM `users`');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE `users` SET `role`=? WHERE `id`=?');
+
+ foreach ($rows as $row) {
+ $role = Role::APP_USER;
+
+ if ($row['is_admin'] == 1) {
+ $role = Role::APP_ADMIN;
+ } else if ($row['is_project_admin']) {
+ $role = Role::APP_MANAGER;
+ }
+
+ $rq->execute(array($role, $row['id']));
+ }
+
+ $pdo->exec('ALTER TABLE `users` DROP COLUMN `is_admin`');
+ $pdo->exec('ALTER TABLE `users` DROP COLUMN `is_project_admin`');
+}
+
+function version_96(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_has_groups (
+ `group_id` INT NOT NULL,
+ `project_id` INT NOT NULL,
+ `role` VARCHAR(25) NOT NULL,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE(group_id, project_id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("ALTER TABLE `project_has_users` ADD COLUMN `role` VARCHAR(25) NOT NULL DEFAULT '".Role::PROJECT_VIEWER."'");
+
+ $rq = $pdo->prepare('SELECT * FROM project_has_users');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE `project_has_users` SET `role`=? WHERE `id`=?');
+
+ foreach ($rows as $row) {
+ $rq->execute(array(
+ $row['is_owner'] == 1 ? Role::PROJECT_MANAGER : Role::PROJECT_MEMBER,
+ $row['id'],
+ ));
+ }
+
+ $pdo->exec('ALTER TABLE `project_has_users` DROP COLUMN `is_owner`');
+ $pdo->exec('ALTER TABLE `project_has_users` DROP COLUMN `id`');
+}
+
+function version_95(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE groups (
+ id INT NOT NULL AUTO_INCREMENT,
+ external_id VARCHAR(255) DEFAULT '',
+ name VARCHAR(100) NOT NULL UNIQUE,
+ PRIMARY KEY(id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE group_has_users (
+ group_id INT NOT NULL,
+ user_id INT NOT NULL,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(group_id, user_id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+}
+
+function version_94(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE `projects` DROP INDEX `name`');
+ $pdo->exec('ALTER TABLE `projects` DROP INDEX `name_2`');
+}
function version_93(PDO $pdo)
{
@@ -869,7 +1069,7 @@ function version_20(PDO $pdo)
function version_19(PDO $pdo)
{
$pdo->exec("ALTER TABLE config ADD COLUMN api_token VARCHAR(255) DEFAULT ''");
- $pdo->exec("UPDATE config SET api_token='".Security::generateToken()."'");
+ $pdo->exec("UPDATE config SET api_token='".Token::getToken()."'");
}
function version_18(PDO $pdo)
@@ -943,7 +1143,7 @@ function version_12(PDO $pdo)
CREATE TABLE remember_me (
id INT NOT NULL AUTO_INCREMENT,
user_id INT,
- ip VARCHAR(40),
+ ip VARCHAR(45),
user_agent VARCHAR(255),
token VARCHAR(255),
sequence VARCHAR(255),
@@ -959,7 +1159,7 @@ function version_12(PDO $pdo)
id INT NOT NULL AUTO_INCREMENT,
auth_type VARCHAR(25),
user_id INT,
- ip VARCHAR(40),
+ ip VARCHAR(45),
user_agent VARCHAR(255),
date_creation INT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -1091,6 +1291,6 @@ function version_1(PDO $pdo)
$pdo->exec("
INSERT INTO config
(webhooks_token)
- VALUES ('".Security::generateToken()."')
+ VALUES ('".Token::getToken()."')
");
}
diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php
index a3fb6d49..6aed1491 100644
--- a/app/Schema/Postgres.php
+++ b/app/Schema/Postgres.php
@@ -3,9 +3,205 @@
namespace Schema;
use PDO;
-use Kanboard\Core\Security;
+use Kanboard\Core\Security\Token;
+use Kanboard\Core\Security\Role;
-const VERSION = 73;
+const VERSION = 87;
+
+function version_87(PDO $pdo)
+{
+ $pdo->exec("UPDATE project_activities SET event_name='task.file.create' WHERE event_name='file.create'");
+}
+
+function version_86(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE files RENAME TO task_has_files');
+
+ $pdo->exec("
+ CREATE TABLE project_has_files (
+ id SERIAL PRIMARY KEY,
+ project_id INTEGER NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ path VARCHAR(255) NOT NULL,
+ is_image BOOLEAN DEFAULT '0',
+ size INTEGER DEFAULT 0 NOT NULL,
+ user_id INTEGER DEFAULT 0 NOT NULL,
+ date INTEGER DEFAULT 0 NOT NULL,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )"
+ );
+}
+
+function version_85(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT '1'");
+}
+
+function version_84(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_external_links (
+ id SERIAL PRIMARY KEY,
+ link_type VARCHAR(100) NOT NULL,
+ dependency VARCHAR(100) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ url VARCHAR(255) NOT NULL,
+ date_creation INT NOT NULL,
+ date_modification INT NOT NULL,
+ task_id INT NOT NULL,
+ creator_id INT DEFAULT 0,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )
+ ");
+}
+
+function version_83(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_default INTEGER DEFAULT 0");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_start INTEGER DEFAULT 0");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_end INTEGER DEFAULT 3");
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0");
+}
+
+function version_82(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN owner_id INTEGER DEFAULT 0");
+}
+
+function version_81(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE password_reset (
+ token VARCHAR(80) PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ date_expiration INTEGER NOT NULL,
+ date_creation INTEGER NOT NULL,
+ ip VARCHAR(45) NOT NULL,
+ user_agent VARCHAR(255) NOT NULL,
+ is_active BOOLEAN NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ ");
+
+ $pdo->exec("INSERT INTO settings VALUES ('password_reset', '1')");
+}
+
+function version_80(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE "actions" ALTER COLUMN "action_name" TYPE VARCHAR(255)');
+}
+
+function version_79(PDO $pdo)
+{
+ $rq = $pdo->prepare('SELECT * FROM actions');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE actions SET action_name=? WHERE id=?');
+
+ foreach ($rows as $row) {
+ if ($row['action_name'] === 'TaskAssignCurrentUser' && $row['event_name'] === 'task.move.column') {
+ $row['action_name'] = '\Kanboard\Action\TaskAssignCurrentUserColumn';
+ } elseif ($row['action_name'] === 'TaskClose' && $row['event_name'] === 'task.move.column') {
+ $row['action_name'] = '\Kanboard\Action\TaskCloseColumn';
+ } elseif ($row['action_name'] === 'TaskLogMoveAnotherColumn') {
+ $row['action_name'] = '\Kanboard\Action\CommentCreationMoveTaskColumn';
+ } elseif ($row['action_name']{0} !== '\\') {
+ $row['action_name'] = '\Kanboard\Action\\'.$row['action_name'];
+ }
+
+ $rq->execute(array($row['action_name'], $row['id']));
+ }
+}
+
+function version_78(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE "users" ALTER COLUMN "language" TYPE VARCHAR(5)');
+}
+
+function version_77(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE "users" ADD COLUMN "role" VARCHAR(25) NOT NULL DEFAULT \''.Role::APP_USER.'\'');
+
+ $rq = $pdo->prepare('SELECT * FROM "users"');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE "users" SET "role"=? WHERE "id"=?');
+
+ foreach ($rows as $row) {
+ $role = Role::APP_USER;
+
+ if ($row['is_admin'] == 1) {
+ $role = Role::APP_ADMIN;
+ } else if ($row['is_project_admin']) {
+ $role = Role::APP_MANAGER;
+ }
+
+ $rq->execute(array($role, $row['id']));
+ }
+
+ $pdo->exec('ALTER TABLE users DROP COLUMN "is_admin"');
+ $pdo->exec('ALTER TABLE users DROP COLUMN "is_project_admin"');
+}
+
+function version_76(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_has_groups (
+ group_id INTEGER NOT NULL,
+ project_id INTEGER NOT NULL,
+ role VARCHAR(25) NOT NULL,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE(group_id, project_id)
+ )
+ ");
+
+ $pdo->exec("ALTER TABLE project_has_users ADD COLUMN role VARCHAR(25) NOT NULL DEFAULT '".Role::PROJECT_VIEWER."'");
+
+ $rq = $pdo->prepare('SELECT * FROM project_has_users');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE project_has_users SET "role"=? WHERE "id"=?');
+
+ foreach ($rows as $row) {
+ $rq->execute(array(
+ $row['is_owner'] == 1 ? Role::PROJECT_MANAGER : Role::PROJECT_MEMBER,
+ $row['id'],
+ ));
+ }
+
+ $pdo->exec('ALTER TABLE project_has_users DROP COLUMN "is_owner"');
+ $pdo->exec('ALTER TABLE project_has_users DROP COLUMN "id"');
+}
+
+function version_75(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE groups (
+ id SERIAL PRIMARY KEY,
+ external_id VARCHAR(255) DEFAULT '',
+ name VARCHAR(100) NOT NULL UNIQUE
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE group_has_users (
+ group_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(group_id, user_id)
+ )
+ ");
+}
+
+function version_74(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_name_key');
+}
function version_73(PDO $pdo)
{
@@ -865,7 +1061,7 @@ function version_1(PDO $pdo)
CREATE TABLE remember_me (
id SERIAL PRIMARY KEY,
user_id INTEGER,
- ip VARCHAR(40),
+ ip VARCHAR(45),
user_agent VARCHAR(255),
token VARCHAR(255),
sequence VARCHAR(255),
@@ -878,7 +1074,7 @@ function version_1(PDO $pdo)
id SERIAL PRIMARY KEY,
auth_type VARCHAR(25),
user_id INTEGER,
- ip VARCHAR(40),
+ ip VARCHAR(45),
user_agent VARCHAR(255),
date_creation INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -994,6 +1190,6 @@ function version_1(PDO $pdo)
$pdo->exec("
INSERT INTO config
(webhooks_token, api_token)
- VALUES ('".Security::generateToken()."', '".Security::generateToken()."')
+ VALUES ('".Token::getToken()."', '".Token::getToken()."')
");
}
diff --git a/app/Schema/Sql/mysql.sql b/app/Schema/Sql/mysql.sql
index bb87a6ca..ef219490 100644
--- a/app/Schema/Sql/mysql.sql
+++ b/app/Schema/Sql/mysql.sql
@@ -29,7 +29,7 @@ CREATE TABLE `actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`project_id` int(11) NOT NULL,
`event_name` varchar(50) NOT NULL,
- `action_name` varchar(50) NOT NULL,
+ `action_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `project_id` (`project_id`),
CONSTRAINT `actions_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
@@ -112,6 +112,29 @@ CREATE TABLE `files` (
CONSTRAINT `files_ibfk_1` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
+DROP TABLE IF EXISTS `group_has_users`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `group_has_users` (
+ `group_id` int(11) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ UNIQUE KEY `group_id` (`group_id`,`user_id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `group_has_users_ibfk_1` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `group_has_users_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
+DROP TABLE IF EXISTS `groups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `groups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `external_id` varchar(255) DEFAULT '',
+ `name` varchar(100) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `name` (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `last_logins`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
@@ -119,7 +142,7 @@ CREATE TABLE `last_logins` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`auth_type` varchar(25) DEFAULT NULL,
`user_id` int(11) DEFAULT NULL,
- `ip` varchar(40) DEFAULT NULL,
+ `ip` varchar(45) DEFAULT NULL,
`user_agent` varchar(255) DEFAULT NULL,
`date_creation` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`),
@@ -138,6 +161,22 @@ CREATE TABLE `links` (
UNIQUE KEY `label` (`label`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
+DROP TABLE IF EXISTS `password_reset`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `password_reset` (
+ `token` varchar(80) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `date_expiration` int(11) NOT NULL,
+ `date_creation` int(11) NOT NULL,
+ `ip` varchar(45) NOT NULL,
+ `user_agent` varchar(255) NOT NULL,
+ `is_active` tinyint(1) NOT NULL,
+ PRIMARY KEY (`token`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `password_reset_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `plugin_schema_versions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
@@ -214,6 +253,19 @@ CREATE TABLE `project_has_categories` (
CONSTRAINT `project_has_categories_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
+DROP TABLE IF EXISTS `project_has_groups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `project_has_groups` (
+ `group_id` int(11) NOT NULL,
+ `project_id` int(11) NOT NULL,
+ `role` varchar(25) NOT NULL,
+ UNIQUE KEY `group_id` (`group_id`,`project_id`),
+ KEY `project_id` (`project_id`),
+ CONSTRAINT `project_has_groups_ibfk_1` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `project_has_groups_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `project_has_metadata`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
@@ -241,11 +293,9 @@ DROP TABLE IF EXISTS `project_has_users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `project_has_users` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
`project_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
- `is_owner` tinyint(1) DEFAULT '0',
- PRIMARY KEY (`id`),
+ `role` varchar(25) NOT NULL DEFAULT 'project-viewer',
UNIQUE KEY `idx_project_user` (`project_id`,`user_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `project_has_users_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
@@ -270,9 +320,11 @@ CREATE TABLE `projects` (
`identifier` varchar(50) DEFAULT '',
`start_date` varchar(10) DEFAULT '',
`end_date` varchar(10) DEFAULT '',
- PRIMARY KEY (`id`),
- UNIQUE KEY `name` (`name`),
- UNIQUE KEY `name_2` (`name`)
+ `owner_id` int(11) DEFAULT '0',
+ `priority_default` int(11) DEFAULT '0',
+ `priority_start` int(11) DEFAULT '0',
+ `priority_end` int(11) DEFAULT '3',
+ PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `remember_me`;
@@ -281,7 +333,7 @@ DROP TABLE IF EXISTS `remember_me`;
CREATE TABLE `remember_me` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
- `ip` varchar(40) DEFAULT NULL,
+ `ip` varchar(45) DEFAULT NULL,
`user_agent` varchar(255) DEFAULT NULL,
`token` varchar(255) DEFAULT NULL,
`sequence` varchar(255) DEFAULT NULL,
@@ -358,6 +410,24 @@ CREATE TABLE `swimlanes` (
CONSTRAINT `swimlanes_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
+DROP TABLE IF EXISTS `task_has_external_links`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `task_has_external_links` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `link_type` varchar(100) NOT NULL,
+ `dependency` varchar(100) NOT NULL,
+ `title` varchar(255) NOT NULL,
+ `url` varchar(255) NOT NULL,
+ `date_creation` int(11) NOT NULL,
+ `date_modification` int(11) NOT NULL,
+ `task_id` int(11) NOT NULL,
+ `creator_id` int(11) DEFAULT '0',
+ PRIMARY KEY (`id`),
+ KEY `task_id` (`task_id`),
+ CONSTRAINT `task_has_external_links_ibfk_1` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `task_has_links`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
@@ -419,6 +489,7 @@ CREATE TABLE `tasks` (
`recurrence_basedate` int(11) NOT NULL DEFAULT '0',
`recurrence_parent` int(11) DEFAULT NULL,
`recurrence_child` int(11) DEFAULT NULL,
+ `priority` int(11) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_task_active` (`is_active`),
KEY `column_id` (`column_id`),
@@ -509,7 +580,6 @@ CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(255) DEFAULT NULL,
- `is_admin` tinyint(4) DEFAULT '0',
`is_ldap_user` tinyint(1) DEFAULT '0',
`name` varchar(255) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
@@ -517,7 +587,7 @@ CREATE TABLE `users` (
`github_id` varchar(30) DEFAULT NULL,
`notifications_enabled` tinyint(1) DEFAULT '0',
`timezone` varchar(50) DEFAULT NULL,
- `language` char(5) DEFAULT NULL,
+ `language` varchar(5) DEFAULT NULL,
`disable_login_form` tinyint(1) DEFAULT '0',
`twofactor_activated` tinyint(1) DEFAULT '0',
`twofactor_secret` char(16) DEFAULT NULL,
@@ -525,11 +595,10 @@ CREATE TABLE `users` (
`notifications_filter` int(11) DEFAULT '4',
`nb_failed_login` int(11) DEFAULT '0',
`lock_expiration_date` bigint(20) DEFAULT NULL,
- `is_project_admin` int(11) DEFAULT '0',
`gitlab_id` int(11) DEFAULT NULL,
+ `role` varchar(25) NOT NULL DEFAULT 'app-user',
PRIMARY KEY (`id`),
- UNIQUE KEY `users_username_idx` (`username`),
- KEY `users_admin_idx` (`is_admin`)
+ UNIQUE KEY `users_username_idx` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
@@ -551,7 +620,7 @@ CREATE TABLE `users` (
LOCK TABLES `settings` WRITE;
/*!40000 ALTER TABLE `settings` DISABLE KEYS */;
-INSERT INTO `settings` VALUES ('api_token','ccff8d37146322410479c8c6707cdaddde840af28ccbd6fbb5a7d7908844'),('application_currency','USD'),('application_date_format','m/d/Y'),('application_language','en_US'),('application_stylesheet',''),('application_timezone','UTC'),('application_url',''),('board_columns',''),('board_highlight_period','172800'),('board_private_refresh_interval','10'),('board_public_refresh_interval','60'),('calendar_project_tasks','date_started'),('calendar_user_subtasks_time_tracking','0'),('calendar_user_tasks','date_started'),('cfd_include_closed_tasks','1'),('default_color','yellow'),('integration_gravatar','0'),('project_categories',''),('subtask_restriction','0'),('subtask_time_tracking','1'),('webhook_token','7a9d4cd8c7fc4d52c60f01b775d4f2bd6e186d2e44f5b3723157b8eb372b'),('webhook_url','');
+INSERT INTO `settings` VALUES ('api_token','dcae89653bfd780f56a5c2ffabc54174bcdd877940e97eaa5d800b35df0c'),('application_currency','USD'),('application_date_format','m/d/Y'),('application_language','en_US'),('application_stylesheet',''),('application_timezone','UTC'),('application_url',''),('board_columns',''),('board_highlight_period','172800'),('board_private_refresh_interval','10'),('board_public_refresh_interval','60'),('calendar_project_tasks','date_started'),('calendar_user_subtasks_time_tracking','0'),('calendar_user_tasks','date_started'),('cfd_include_closed_tasks','1'),('default_color','yellow'),('integration_gravatar','0'),('password_reset','1'),('project_categories',''),('subtask_restriction','0'),('subtask_time_tracking','1'),('webhook_token','6c0a16d06e31add3ca1ca8713a69c6b410f72c80fd49cdfe27c4a62eef13'),('webhook_url','');
/*!40000 ALTER TABLE `settings` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
@@ -580,4 +649,4 @@ UNLOCK TABLES;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-INSERT INTO users (username, password, is_admin) VALUES ('admin', '$2y$10$fDbO.nKAjDxm70DyghADCuqIhF919BAkRTAq0bARDTGwcxZscqIZq', '1');INSERT INTO schema_version VALUES ('93');
+INSERT INTO users (username, password, role) VALUES ('admin', '$2y$10$f6brAPgJ6iTtOWUMJANDuuRG.VWDH61.aCb5v3MJnSrZodhDaCcmy', 'app-admin');INSERT INTO schema_version VALUES ('104');
diff --git a/app/Schema/Sql/postgres.sql b/app/Schema/Sql/postgres.sql
index 8738010f..68415805 100644
--- a/app/Schema/Sql/postgres.sql
+++ b/app/Schema/Sql/postgres.sql
@@ -68,7 +68,7 @@ CREATE TABLE actions (
id integer NOT NULL,
project_id integer NOT NULL,
event_name character varying(50) NOT NULL,
- action_name character varying(50) NOT NULL
+ action_name character varying(255) NOT NULL
);
@@ -218,6 +218,46 @@ CREATE TABLE files (
--
+-- Name: group_has_users; Type: TABLE; Schema: public; Owner: -; Tablespace:
+--
+
+CREATE TABLE group_has_users (
+ group_id integer NOT NULL,
+ user_id integer NOT NULL
+);
+
+
+--
+-- Name: groups; Type: TABLE; Schema: public; Owner: -; Tablespace:
+--
+
+CREATE TABLE groups (
+ id integer NOT NULL,
+ external_id character varying(255) DEFAULT ''::character varying,
+ name character varying(100) NOT NULL
+);
+
+
+--
+-- Name: groups_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE groups_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: groups_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE groups_id_seq OWNED BY groups.id;
+
+
+--
-- Name: last_logins; Type: TABLE; Schema: public; Owner: -; Tablespace:
--
@@ -225,7 +265,7 @@ CREATE TABLE last_logins (
id integer NOT NULL,
auth_type character varying(25),
user_id integer,
- ip character varying(40),
+ ip character varying(45),
user_agent character varying(255),
date_creation bigint
);
@@ -281,6 +321,21 @@ ALTER SEQUENCE links_id_seq OWNED BY links.id;
--
+-- Name: password_reset; Type: TABLE; Schema: public; Owner: -; Tablespace:
+--
+
+CREATE TABLE password_reset (
+ token character varying(80) NOT NULL,
+ user_id integer NOT NULL,
+ date_expiration integer NOT NULL,
+ date_creation integer NOT NULL,
+ ip character varying(45) NOT NULL,
+ user_agent character varying(255) NOT NULL,
+ is_active boolean NOT NULL
+);
+
+
+--
-- Name: plugin_schema_versions; Type: TABLE; Schema: public; Owner: -; Tablespace:
--
@@ -421,6 +476,17 @@ ALTER SEQUENCE project_has_categories_id_seq OWNED BY project_has_categories.id;
--
+-- Name: project_has_groups; Type: TABLE; Schema: public; Owner: -; Tablespace:
+--
+
+CREATE TABLE project_has_groups (
+ group_id integer NOT NULL,
+ project_id integer NOT NULL,
+ role character varying(25) NOT NULL
+);
+
+
+--
-- Name: project_has_metadata; Type: TABLE; Schema: public; Owner: -; Tablespace:
--
@@ -466,33 +532,13 @@ ALTER SEQUENCE project_has_notification_types_id_seq OWNED BY project_has_notifi
--
CREATE TABLE project_has_users (
- id integer NOT NULL,
project_id integer NOT NULL,
user_id integer NOT NULL,
- is_owner boolean DEFAULT false
+ role character varying(25) DEFAULT 'project-viewer'::character varying NOT NULL
);
--
--- Name: project_has_users_id_seq; Type: SEQUENCE; Schema: public; Owner: -
---
-
-CREATE SEQUENCE project_has_users_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1;
-
-
---
--- Name: project_has_users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
---
-
-ALTER SEQUENCE project_has_users_id_seq OWNED BY project_has_users.id;
-
-
---
-- Name: projects; Type: TABLE; Schema: public; Owner: -; Tablespace:
--
@@ -510,7 +556,11 @@ CREATE TABLE projects (
description text,
identifier character varying(50) DEFAULT ''::character varying,
start_date character varying(10) DEFAULT ''::character varying,
- end_date character varying(10) DEFAULT ''::character varying
+ end_date character varying(10) DEFAULT ''::character varying,
+ owner_id integer DEFAULT 0,
+ priority_default integer DEFAULT 0,
+ priority_start integer DEFAULT 0,
+ priority_end integer DEFAULT 3
);
@@ -540,7 +590,7 @@ ALTER SEQUENCE projects_id_seq OWNED BY projects.id;
CREATE TABLE remember_me (
id integer NOT NULL,
user_id integer,
- ip character varying(40),
+ ip character varying(45),
user_agent character varying(255),
token character varying(255),
sequence character varying(255),
@@ -670,6 +720,42 @@ ALTER SEQUENCE swimlanes_id_seq OWNED BY swimlanes.id;
--
+-- Name: task_has_external_links; Type: TABLE; Schema: public; Owner: -; Tablespace:
+--
+
+CREATE TABLE task_has_external_links (
+ id integer NOT NULL,
+ link_type character varying(100) NOT NULL,
+ dependency character varying(100) NOT NULL,
+ title character varying(255) NOT NULL,
+ url character varying(255) NOT NULL,
+ date_creation integer NOT NULL,
+ date_modification integer NOT NULL,
+ task_id integer NOT NULL,
+ creator_id integer DEFAULT 0
+);
+
+
+--
+-- Name: task_has_external_links_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE task_has_external_links_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: task_has_external_links_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE task_has_external_links_id_seq OWNED BY task_has_external_links.id;
+
+
+--
-- Name: task_has_files_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
@@ -782,7 +868,8 @@ CREATE TABLE tasks (
recurrence_timeframe integer DEFAULT 0 NOT NULL,
recurrence_basedate integer DEFAULT 0 NOT NULL,
recurrence_parent integer,
- recurrence_child integer
+ recurrence_child integer,
+ priority integer DEFAULT 0
);
@@ -931,7 +1018,6 @@ CREATE TABLE users (
id integer NOT NULL,
username character varying(50) NOT NULL,
password character varying(255),
- is_admin boolean DEFAULT false,
is_ldap_user boolean DEFAULT false,
name character varying(255),
email character varying(255),
@@ -939,7 +1025,7 @@ CREATE TABLE users (
github_id character varying(30),
notifications_enabled boolean DEFAULT false,
timezone character varying(50),
- language character(5),
+ language character varying(5),
disable_login_form boolean DEFAULT false,
twofactor_activated boolean DEFAULT false,
twofactor_secret character(16),
@@ -947,8 +1033,8 @@ CREATE TABLE users (
notifications_filter integer DEFAULT 4,
nb_failed_login integer DEFAULT 0,
lock_expiration_date bigint DEFAULT 0,
- is_project_admin boolean DEFAULT false,
- gitlab_id integer
+ gitlab_id integer,
+ role character varying(25) DEFAULT 'app-user'::character varying NOT NULL
);
@@ -1017,6 +1103,13 @@ ALTER TABLE ONLY files ALTER COLUMN id SET DEFAULT nextval('task_has_files_id_se
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
+ALTER TABLE ONLY groups ALTER COLUMN id SET DEFAULT nextval('groups_id_seq'::regclass);
+
+
+--
+-- Name: id; Type: DEFAULT; Schema: public; Owner: -
+--
+
ALTER TABLE ONLY last_logins ALTER COLUMN id SET DEFAULT nextval('last_logins_id_seq'::regclass);
@@ -1066,42 +1159,42 @@ ALTER TABLE ONLY project_has_notification_types ALTER COLUMN id SET DEFAULT next
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
-ALTER TABLE ONLY project_has_users ALTER COLUMN id SET DEFAULT nextval('project_has_users_id_seq'::regclass);
+ALTER TABLE ONLY projects ALTER COLUMN id SET DEFAULT nextval('projects_id_seq'::regclass);
--
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
-ALTER TABLE ONLY projects ALTER COLUMN id SET DEFAULT nextval('projects_id_seq'::regclass);
+ALTER TABLE ONLY remember_me ALTER COLUMN id SET DEFAULT nextval('remember_me_id_seq'::regclass);
--
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
-ALTER TABLE ONLY remember_me ALTER COLUMN id SET DEFAULT nextval('remember_me_id_seq'::regclass);
+ALTER TABLE ONLY subtask_time_tracking ALTER COLUMN id SET DEFAULT nextval('subtask_time_tracking_id_seq'::regclass);
--
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
-ALTER TABLE ONLY subtask_time_tracking ALTER COLUMN id SET DEFAULT nextval('subtask_time_tracking_id_seq'::regclass);
+ALTER TABLE ONLY subtasks ALTER COLUMN id SET DEFAULT nextval('task_has_subtasks_id_seq'::regclass);
--
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
-ALTER TABLE ONLY subtasks ALTER COLUMN id SET DEFAULT nextval('task_has_subtasks_id_seq'::regclass);
+ALTER TABLE ONLY swimlanes ALTER COLUMN id SET DEFAULT nextval('swimlanes_id_seq'::regclass);
--
-- Name: id; Type: DEFAULT; Schema: public; Owner: -
--
-ALTER TABLE ONLY swimlanes ALTER COLUMN id SET DEFAULT nextval('swimlanes_id_seq'::regclass);
+ALTER TABLE ONLY task_has_external_links ALTER COLUMN id SET DEFAULT nextval('task_has_external_links_id_seq'::regclass);
--
@@ -1203,6 +1296,30 @@ ALTER TABLE ONLY custom_filters
--
+-- Name: group_has_users_group_id_user_id_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
+--
+
+ALTER TABLE ONLY group_has_users
+ ADD CONSTRAINT group_has_users_group_id_user_id_key UNIQUE (group_id, user_id);
+
+
+--
+-- Name: groups_name_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
+--
+
+ALTER TABLE ONLY groups
+ ADD CONSTRAINT groups_name_key UNIQUE (name);
+
+
+--
+-- Name: groups_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
+--
+
+ALTER TABLE ONLY groups
+ ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
+
+
+--
-- Name: last_logins_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
--
@@ -1227,6 +1344,14 @@ ALTER TABLE ONLY links
--
+-- Name: password_reset_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
+--
+
+ALTER TABLE ONLY password_reset
+ ADD CONSTRAINT password_reset_pkey PRIMARY KEY (token);
+
+
+--
-- Name: plugin_schema_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
--
@@ -1275,6 +1400,14 @@ ALTER TABLE ONLY project_has_categories
--
+-- Name: project_has_groups_group_id_project_id_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
+--
+
+ALTER TABLE ONLY project_has_groups
+ ADD CONSTRAINT project_has_groups_group_id_project_id_key UNIQUE (group_id, project_id);
+
+
+--
-- Name: project_has_metadata_project_id_name_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
--
@@ -1299,14 +1432,6 @@ ALTER TABLE ONLY project_has_notification_types
--
--- Name: project_has_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
---
-
-ALTER TABLE ONLY project_has_users
- ADD CONSTRAINT project_has_users_pkey PRIMARY KEY (id);
-
-
---
-- Name: project_has_users_project_id_user_id_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
--
@@ -1315,14 +1440,6 @@ ALTER TABLE ONLY project_has_users
--
--- Name: projects_name_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
---
-
-ALTER TABLE ONLY projects
- ADD CONSTRAINT projects_name_key UNIQUE (name);
-
-
---
-- Name: projects_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace:
--
@@ -1578,13 +1695,6 @@ CREATE UNIQUE INDEX user_has_notification_types_user_idx ON user_has_notificatio
--
--- Name: users_admin_idx; Type: INDEX; Schema: public; Owner: -; Tablespace:
---
-
-CREATE INDEX users_admin_idx ON users USING btree (is_admin);
-
-
---
-- Name: users_username_idx; Type: INDEX; Schema: public; Owner: -; Tablespace:
--
@@ -1624,6 +1734,22 @@ ALTER TABLE ONLY comments
--
+-- Name: group_has_users_group_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY group_has_users
+ ADD CONSTRAINT group_has_users_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
+
+
+--
+-- Name: group_has_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY group_has_users
+ ADD CONSTRAINT group_has_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
+
+--
-- Name: last_logins_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -1632,6 +1758,14 @@ ALTER TABLE ONLY last_logins
--
+-- Name: password_reset_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY password_reset
+ ADD CONSTRAINT password_reset_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
+
+--
-- Name: project_activities_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -1688,6 +1822,22 @@ ALTER TABLE ONLY project_has_categories
--
+-- Name: project_has_groups_group_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY project_has_groups
+ ADD CONSTRAINT project_has_groups_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
+
+
+--
+-- Name: project_has_groups_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY project_has_groups
+ ADD CONSTRAINT project_has_groups_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
+
+--
-- Name: project_has_metadata_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -1752,6 +1902,14 @@ ALTER TABLE ONLY swimlanes
--
+-- Name: task_has_external_links_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY task_has_external_links
+ ADD CONSTRAINT task_has_external_links_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE;
+
+
+--
-- Name: task_has_files_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -1930,8 +2088,8 @@ INSERT INTO settings (option, value) VALUES ('board_highlight_period', '172800')
INSERT INTO settings (option, value) VALUES ('board_public_refresh_interval', '60');
INSERT INTO settings (option, value) VALUES ('board_private_refresh_interval', '10');
INSERT INTO settings (option, value) VALUES ('board_columns', '');
-INSERT INTO settings (option, value) VALUES ('webhook_token', '29877f0b69d230e57bee9d02e0aa9034a69f7a2c0ba1e3b5d3b390241f36');
-INSERT INTO settings (option, value) VALUES ('api_token', '5682955e965bd0cd7618559a25131fe6094d9fff3bb56c31291d64991353');
+INSERT INTO settings (option, value) VALUES ('webhook_token', '083531659240f36021edc73a4ed3920646ef8798b798c5894e2277f048a2');
+INSERT INTO settings (option, value) VALUES ('api_token', 'b97b4abc7e8f0e0569bdad8cb54f7fb1931615932e552fa255f87a3e1360');
INSERT INTO settings (option, value) VALUES ('application_language', 'en_US');
INSERT INTO settings (option, value) VALUES ('application_timezone', 'UTC');
INSERT INTO settings (option, value) VALUES ('application_url', '');
@@ -1948,6 +2106,7 @@ INSERT INTO settings (option, value) VALUES ('webhook_url', '');
INSERT INTO settings (option, value) VALUES ('default_color', 'yellow');
INSERT INTO settings (option, value) VALUES ('subtask_time_tracking', '1');
INSERT INTO settings (option, value) VALUES ('cfd_include_closed_tasks', '1');
+INSERT INTO settings (option, value) VALUES ('password_reset', '1');
--
@@ -1995,4 +2154,4 @@ SELECT pg_catalog.setval('links_id_seq', 11, true);
-- PostgreSQL database dump complete
--
-INSERT INTO users (username, password, is_admin) VALUES ('admin', '$2y$10$fDbO.nKAjDxm70DyghADCuqIhF919BAkRTAq0bARDTGwcxZscqIZq', '1');INSERT INTO schema_version VALUES ('73');
+INSERT INTO users (username, password, role) VALUES ('admin', '$2y$10$f6brAPgJ6iTtOWUMJANDuuRG.VWDH61.aCb5v3MJnSrZodhDaCcmy', 'app-admin');INSERT INTO schema_version VALUES ('84');
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index b9ab86f8..8bcad291 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -2,10 +2,185 @@
namespace Schema;
-use Kanboard\Core\Security;
+use Kanboard\Core\Security\Token;
+use Kanboard\Core\Security\Role;
use PDO;
-const VERSION = 88;
+const VERSION = 99;
+
+function version_99(PDO $pdo)
+{
+ $pdo->exec("UPDATE project_activities SET event_name='task.file.create' WHERE event_name='file.create'");
+}
+
+function version_98(PDO $pdo)
+{
+ $pdo->exec('ALTER TABLE files RENAME TO task_has_files');
+
+ $pdo->exec("
+ CREATE TABLE project_has_files (
+ id INTEGER PRIMARY KEY,
+ project_id INTEGER NOT NULL,
+ name TEXT COLLATE NOCASE NOT NULL,
+ path TEXT NOT NULL,
+ is_image INTEGER DEFAULT 0,
+ size INTEGER DEFAULT 0 NOT NULL,
+ user_id INTEGER DEFAULT 0 NOT NULL,
+ date INTEGER DEFAULT 0 NOT NULL,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )"
+ );
+}
+
+function version_97(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1");
+}
+
+function version_96(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_external_links (
+ id INTEGER PRIMARY KEY,
+ link_type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ title TEXT NOT NULL,
+ url TEXT NOT NULL,
+ date_creation INTEGER NOT NULL,
+ date_modification INTEGER NOT NULL,
+ task_id INTEGER NOT NULL,
+ creator_id INTEGER DEFAULT 0,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )
+ ");
+}
+
+function version_95(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_default INTEGER DEFAULT 0");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_start INTEGER DEFAULT 0");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN priority_end INTEGER DEFAULT 3");
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0");
+}
+
+function version_94(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN owner_id INTEGER DEFAULT 0");
+}
+
+function version_93(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE password_reset (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ date_expiration INTEGER NOT NULL,
+ date_creation INTEGER NOT NULL,
+ ip TEXT NOT NULL,
+ user_agent TEXT NOT NULL,
+ is_active INTEGER NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ ");
+
+ $pdo->exec("INSERT INTO settings VALUES ('password_reset', '1')");
+}
+
+function version_92(PDO $pdo)
+{
+ $rq = $pdo->prepare('SELECT * FROM actions');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE actions SET action_name=? WHERE id=?');
+
+ foreach ($rows as $row) {
+ if ($row['action_name'] === 'TaskAssignCurrentUser' && $row['event_name'] === 'task.move.column') {
+ $row['action_name'] = '\Kanboard\Action\TaskAssignCurrentUserColumn';
+ } elseif ($row['action_name'] === 'TaskClose' && $row['event_name'] === 'task.move.column') {
+ $row['action_name'] = '\Kanboard\Action\TaskCloseColumn';
+ } elseif ($row['action_name'] === 'TaskLogMoveAnotherColumn') {
+ $row['action_name'] = '\Kanboard\Action\CommentCreationMoveTaskColumn';
+ } elseif ($row['action_name']{0} !== '\\') {
+ $row['action_name'] = '\Kanboard\Action\\'.$row['action_name'];
+ }
+
+ $rq->execute(array($row['action_name'], $row['id']));
+ }
+}
+
+function version_91(PDO $pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT '".Role::APP_USER."'");
+
+ $rq = $pdo->prepare('SELECT * FROM users');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE users SET "role"=? WHERE "id"=?');
+
+ foreach ($rows as $row) {
+ $role = Role::APP_USER;
+
+ if ($row['is_admin'] == 1) {
+ $role = Role::APP_ADMIN;
+ } else if ($row['is_project_admin']) {
+ $role = Role::APP_MANAGER;
+ }
+
+ $rq->execute(array($role, $row['id']));
+ }
+}
+
+function version_90(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_has_groups (
+ group_id INTEGER NOT NULL,
+ project_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE(group_id, project_id)
+ )
+ ");
+
+ $pdo->exec("ALTER TABLE project_has_users ADD COLUMN role TEXT NOT NULL DEFAULT '".Role::PROJECT_VIEWER."'");
+
+ $rq = $pdo->prepare('SELECT * FROM project_has_users');
+ $rq->execute();
+ $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array();
+
+ $rq = $pdo->prepare('UPDATE project_has_users SET "role"=? WHERE "id"=?');
+
+ foreach ($rows as $row) {
+ $rq->execute(array(
+ $row['is_owner'] == 1 ? Role::PROJECT_MANAGER : Role::PROJECT_MEMBER,
+ $row['id'],
+ ));
+ }
+}
+
+function version_89(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE groups (
+ id INTEGER PRIMARY KEY,
+ external_id TEXT DEFAULT '',
+ name TEXT NOCASE NOT NULL UNIQUE
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE group_has_users (
+ group_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(group_id, user_id)
+ )
+ ");
+}
function version_88(PDO $pdo)
{
@@ -799,7 +974,7 @@ function version_20(PDO $pdo)
function version_19(PDO $pdo)
{
$pdo->exec("ALTER TABLE config ADD COLUMN api_token TEXT DEFAULT ''");
- $pdo->exec("UPDATE config SET api_token='".Security::generateToken()."'");
+ $pdo->exec("UPDATE config SET api_token='".Token::getToken()."'");
}
function version_18(PDO $pdo)
@@ -969,7 +1144,6 @@ function version_7(PDO $pdo)
{
$pdo->exec("
CREATE TABLE project_has_users (
- id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
@@ -1026,7 +1200,7 @@ function version_1(PDO $pdo)
$pdo->exec("
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
- name TEXT NOCASE NOT NULL UNIQUE,
+ name TEXT NOCASE NOT NULL,
is_active INTEGER DEFAULT 1
)
");
@@ -1068,6 +1242,6 @@ function version_1(PDO $pdo)
$pdo->exec("
INSERT INTO config
(webhooks_token)
- VALUES ('".Security::generateToken()."')
+ VALUES ('".Token::getToken()."')
");
}
diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php
new file mode 100644
index 00000000..3692f190
--- /dev/null
+++ b/app/ServiceProvider/ActionProvider.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\Action\ActionManager;
+use Kanboard\Action\CommentCreation;
+use Kanboard\Action\CommentCreationMoveTaskColumn;
+use Kanboard\Action\TaskAssignCategoryColor;
+use Kanboard\Action\TaskAssignCategoryLabel;
+use Kanboard\Action\TaskAssignCategoryLink;
+use Kanboard\Action\TaskAssignColorCategory;
+use Kanboard\Action\TaskAssignColorColumn;
+use Kanboard\Action\TaskAssignColorLink;
+use Kanboard\Action\TaskAssignColorUser;
+use Kanboard\Action\TaskAssignCurrentUser;
+use Kanboard\Action\TaskAssignCurrentUserColumn;
+use Kanboard\Action\TaskAssignSpecificUser;
+use Kanboard\Action\TaskAssignUser;
+use Kanboard\Action\TaskClose;
+use Kanboard\Action\TaskCloseColumn;
+use Kanboard\Action\TaskCreation;
+use Kanboard\Action\TaskDuplicateAnotherProject;
+use Kanboard\Action\TaskEmail;
+use Kanboard\Action\TaskEmailNoActivity;
+use Kanboard\Action\TaskMoveAnotherProject;
+use Kanboard\Action\TaskMoveColumnAssigned;
+use Kanboard\Action\TaskMoveColumnCategoryChange;
+use Kanboard\Action\TaskMoveColumnUnAssigned;
+use Kanboard\Action\TaskOpen;
+use Kanboard\Action\TaskUpdateStartDate;
+use Kanboard\Action\TaskCloseNoActivity;
+
+/**
+ * Action Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class ActionProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['actionManager'] = new ActionManager($container);
+ $container['actionManager']->register(new CommentCreation($container));
+ $container['actionManager']->register(new CommentCreationMoveTaskColumn($container));
+ $container['actionManager']->register(new TaskAssignCategoryColor($container));
+ $container['actionManager']->register(new TaskAssignCategoryLabel($container));
+ $container['actionManager']->register(new TaskAssignCategoryLink($container));
+ $container['actionManager']->register(new TaskAssignColorCategory($container));
+ $container['actionManager']->register(new TaskAssignColorColumn($container));
+ $container['actionManager']->register(new TaskAssignColorLink($container));
+ $container['actionManager']->register(new TaskAssignColorUser($container));
+ $container['actionManager']->register(new TaskAssignCurrentUser($container));
+ $container['actionManager']->register(new TaskAssignCurrentUserColumn($container));
+ $container['actionManager']->register(new TaskAssignSpecificUser($container));
+ $container['actionManager']->register(new TaskAssignUser($container));
+ $container['actionManager']->register(new TaskClose($container));
+ $container['actionManager']->register(new TaskCloseColumn($container));
+ $container['actionManager']->register(new TaskCloseNoActivity($container));
+ $container['actionManager']->register(new TaskCreation($container));
+ $container['actionManager']->register(new TaskDuplicateAnotherProject($container));
+ $container['actionManager']->register(new TaskEmail($container));
+ $container['actionManager']->register(new TaskEmailNoActivity($container));
+ $container['actionManager']->register(new TaskMoveAnotherProject($container));
+ $container['actionManager']->register(new TaskMoveColumnAssigned($container));
+ $container['actionManager']->register(new TaskMoveColumnCategoryChange($container));
+ $container['actionManager']->register(new TaskMoveColumnUnAssigned($container));
+ $container['actionManager']->register(new TaskOpen($container));
+ $container['actionManager']->register(new TaskUpdateStartDate($container));
+
+ return $container;
+ }
+}
diff --git a/app/ServiceProvider/AuthenticationProvider.php b/app/ServiceProvider/AuthenticationProvider.php
new file mode 100644
index 00000000..700fe05b
--- /dev/null
+++ b/app/ServiceProvider/AuthenticationProvider.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\Security\AuthenticationManager;
+use Kanboard\Core\Security\AccessMap;
+use Kanboard\Core\Security\Authorization;
+use Kanboard\Core\Security\Role;
+use Kanboard\Auth\RememberMeAuth;
+use Kanboard\Auth\DatabaseAuth;
+use Kanboard\Auth\LdapAuth;
+use Kanboard\Auth\TotpAuth;
+use Kanboard\Auth\ReverseProxyAuth;
+
+/**
+ * Authentication Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class AuthenticationProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['authenticationManager'] = new AuthenticationManager($container);
+ $container['authenticationManager']->register(new TotpAuth($container));
+ $container['authenticationManager']->register(new RememberMeAuth($container));
+ $container['authenticationManager']->register(new DatabaseAuth($container));
+
+ if (REVERSE_PROXY_AUTH) {
+ $container['authenticationManager']->register(new ReverseProxyAuth($container));
+ }
+
+ if (LDAP_AUTH) {
+ $container['authenticationManager']->register(new LdapAuth($container));
+ }
+
+ $container['projectAccessMap'] = $this->getProjectAccessMap();
+ $container['applicationAccessMap'] = $this->getApplicationAccessMap();
+
+ $container['projectAuthorization'] = new Authorization($container['projectAccessMap']);
+ $container['applicationAuthorization'] = new Authorization($container['applicationAccessMap']);
+
+ return $container;
+ }
+
+ /**
+ * Get ACL for projects
+ *
+ * @access public
+ * @return AccessMap
+ */
+ public function getProjectAccessMap()
+ {
+ $acl = new AccessMap;
+ $acl->setDefaultRole(Role::PROJECT_VIEWER);
+ $acl->setRoleHierarchy(Role::PROJECT_MANAGER, array(Role::PROJECT_MEMBER, Role::PROJECT_VIEWER));
+ $acl->setRoleHierarchy(Role::PROJECT_MEMBER, array(Role::PROJECT_VIEWER));
+
+ $acl->add('Action', '*', Role::PROJECT_MANAGER);
+ $acl->add('Analytic', '*', Role::PROJECT_MANAGER);
+ $acl->add('Board', 'save', Role::PROJECT_MEMBER);
+ $acl->add('BoardPopover', '*', Role::PROJECT_MEMBER);
+ $acl->add('Calendar', 'save', Role::PROJECT_MEMBER);
+ $acl->add('Category', '*', Role::PROJECT_MANAGER);
+ $acl->add('Column', '*', Role::PROJECT_MANAGER);
+ $acl->add('Comment', '*', Role::PROJECT_MEMBER);
+ $acl->add('Customfilter', '*', Role::PROJECT_MEMBER);
+ $acl->add('Export', '*', Role::PROJECT_MANAGER);
+ $acl->add('TaskFile', array('screenshot', 'create', 'save', 'remove', 'confirm'), Role::PROJECT_MEMBER);
+ $acl->add('Gantt', '*', Role::PROJECT_MANAGER);
+ $acl->add('Project', array('share', 'integrations', 'notifications', 'duplicate', 'disable', 'enable', 'remove'), Role::PROJECT_MANAGER);
+ $acl->add('ProjectPermission', '*', Role::PROJECT_MANAGER);
+ $acl->add('ProjectEdit', '*', Role::PROJECT_MANAGER);
+ $acl->add('ProjectFile', '*', Role::PROJECT_MEMBER);
+ $acl->add('Projectuser', '*', Role::PROJECT_MANAGER);
+ $acl->add('Subtask', '*', Role::PROJECT_MEMBER);
+ $acl->add('SubtaskRestriction', '*', Role::PROJECT_MEMBER);
+ $acl->add('SubtaskStatus', '*', Role::PROJECT_MEMBER);
+ $acl->add('Swimlane', '*', Role::PROJECT_MANAGER);
+ $acl->add('Task', 'remove', Role::PROJECT_MEMBER);
+ $acl->add('Taskcreation', '*', Role::PROJECT_MEMBER);
+ $acl->add('Taskduplication', '*', Role::PROJECT_MEMBER);
+ $acl->add('TaskRecurrence', '*', Role::PROJECT_MEMBER);
+ $acl->add('TaskImport', '*', Role::PROJECT_MANAGER);
+ $acl->add('Tasklink', '*', Role::PROJECT_MEMBER);
+ $acl->add('Tasklink', array('show'), Role::PROJECT_VIEWER);
+ $acl->add('TaskExternalLink', '*', Role::PROJECT_MEMBER);
+ $acl->add('TaskExternalLink', array('show'), Role::PROJECT_VIEWER);
+ $acl->add('Taskmodification', '*', Role::PROJECT_MEMBER);
+ $acl->add('Taskstatus', '*', Role::PROJECT_MEMBER);
+ $acl->add('UserHelper', array('mention'), Role::PROJECT_MEMBER);
+
+ return $acl;
+ }
+
+ /**
+ * Get ACL for the application
+ *
+ * @access public
+ * @return AccessMap
+ */
+ public function getApplicationAccessMap()
+ {
+ $acl = new AccessMap;
+ $acl->setDefaultRole(Role::APP_USER);
+ $acl->setRoleHierarchy(Role::APP_ADMIN, array(Role::APP_MANAGER, Role::APP_USER, Role::APP_PUBLIC));
+ $acl->setRoleHierarchy(Role::APP_MANAGER, array(Role::APP_USER, Role::APP_PUBLIC));
+ $acl->setRoleHierarchy(Role::APP_USER, array(Role::APP_PUBLIC));
+
+ $acl->add('Auth', array('login', 'check'), Role::APP_PUBLIC);
+ $acl->add('Captcha', '*', Role::APP_PUBLIC);
+ $acl->add('PasswordReset', '*', Role::APP_PUBLIC);
+ $acl->add('Webhook', '*', Role::APP_PUBLIC);
+ $acl->add('Task', 'readonly', Role::APP_PUBLIC);
+ $acl->add('Board', 'readonly', Role::APP_PUBLIC);
+ $acl->add('Ical', '*', Role::APP_PUBLIC);
+ $acl->add('Feed', '*', Role::APP_PUBLIC);
+
+ $acl->add('Config', '*', Role::APP_ADMIN);
+ $acl->add('Currency', '*', Role::APP_ADMIN);
+ $acl->add('Gantt', array('projects', 'saveProjectDate'), Role::APP_MANAGER);
+ $acl->add('Group', '*', Role::APP_ADMIN);
+ $acl->add('Link', '*', Role::APP_ADMIN);
+ $acl->add('ProjectCreation', 'create', Role::APP_MANAGER);
+ $acl->add('Projectuser', '*', Role::APP_MANAGER);
+ $acl->add('Twofactor', 'disable', Role::APP_ADMIN);
+ $acl->add('UserImport', '*', Role::APP_ADMIN);
+ $acl->add('User', array('index', 'create', 'save', 'authentication'), Role::APP_ADMIN);
+ $acl->add('UserStatus', '*', Role::APP_ADMIN);
+
+ return $acl;
+ }
+}
diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php
index c103d639..0f2fbab5 100644
--- a/app/ServiceProvider/ClassProvider.php
+++ b/app/ServiceProvider/ClassProvider.php
@@ -4,44 +4,55 @@ namespace Kanboard\ServiceProvider;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
-use League\HTMLToMarkdown\HtmlConverter;
-use Kanboard\Core\Plugin\Loader;
use Kanboard\Core\Mail\Client as EmailClient;
use Kanboard\Core\ObjectStorage\FileStorage;
use Kanboard\Core\Paginator;
-use Kanboard\Core\OAuth2;
+use Kanboard\Core\Http\OAuth2;
use Kanboard\Core\Tool;
-use Kanboard\Model\UserNotificationType;
-use Kanboard\Model\ProjectNotificationType;
+use Kanboard\Core\Http\Client as HttpClient;
class ClassProvider implements ServiceProviderInterface
{
private $classes = array(
+ 'Analytic' => array(
+ 'TaskDistributionAnalytic',
+ 'UserDistributionAnalytic',
+ 'EstimatedTimeComparisonAnalytic',
+ 'AverageLeadCycleTimeAnalytic',
+ 'AverageTimeSpentColumnAnalytic',
+ ),
'Model' => array(
- 'Acl',
'Action',
- 'Authentication',
+ 'ActionParameter',
'Board',
'Category',
'Color',
+ 'Column',
'Comment',
'Config',
'Currency',
'CustomFilter',
- 'File',
+ 'Group',
+ 'GroupMember',
'LastLogin',
'Link',
'Notification',
'OverdueNotification',
+ 'PasswordReset',
'Project',
+ 'ProjectFile',
'ProjectActivity',
- 'ProjectAnalytic',
'ProjectDuplication',
'ProjectDailyColumnStats',
'ProjectDailyStats',
'ProjectPermission',
'ProjectNotification',
'ProjectMetadata',
+ 'ProjectGroupRole',
+ 'ProjectGroupRoleFilter',
+ 'ProjectUserRole',
+ 'ProjectUserRoleFilter',
+ 'RememberMeSession',
'Subtask',
'SubtaskExport',
'SubtaskTimeTracking',
@@ -51,22 +62,23 @@ class ClassProvider implements ServiceProviderInterface
'TaskCreation',
'TaskDuplication',
'TaskExport',
+ 'TaskExternalLink',
'TaskFinder',
+ 'TaskFile',
'TaskFilter',
'TaskLink',
'TaskModification',
'TaskPermission',
'TaskPosition',
'TaskStatus',
- 'TaskValidator',
'TaskImport',
'TaskMetadata',
'Transition',
'User',
'UserImport',
- 'UserSession',
+ 'UserLocking',
+ 'UserMention',
'UserNotification',
- 'UserNotificationType',
'UserNotificationFilter',
'UserUnreadNotification',
'UserMetadata',
@@ -77,27 +89,57 @@ class ClassProvider implements ServiceProviderInterface
'TaskFilterCalendarFormatter',
'TaskFilterICalendarFormatter',
'ProjectGanttFormatter',
+ 'UserFilterAutoCompleteFormatter',
+ 'GroupAutoCompleteFormatter',
+ ),
+ 'Validator' => array(
+ 'ActionValidator',
+ 'AuthValidator',
+ 'CategoryValidator',
+ 'ColumnValidator',
+ 'CommentValidator',
+ 'CurrencyValidator',
+ 'CustomFilterValidator',
+ 'ExternalLinkValidator',
+ 'GroupValidator',
+ 'LinkValidator',
+ 'PasswordResetValidator',
+ 'ProjectValidator',
+ 'SubtaskValidator',
+ 'SwimlaneValidator',
+ 'TaskValidator',
+ 'TaskLinkValidator',
+ 'UserValidator',
),
'Core' => array(
'DateParser',
'Helper',
- 'HttpClient',
'Lexer',
- 'Request',
- 'Router',
- 'Session',
'Template',
),
+ 'Core\Event' => array(
+ 'EventManager',
+ ),
+ 'Core\Http' => array(
+ 'Request',
+ 'Response',
+ 'RememberMeCookie',
+ ),
'Core\Cache' => array(
'MemoryCache',
),
'Core\Plugin' => array(
'Hook',
),
- 'Integration' => array(
- 'BitbucketWebhook',
- 'GithubWebhook',
- 'GitlabWebhook',
+ 'Core\Security' => array(
+ 'Token',
+ 'Role',
+ ),
+ 'Core\User' => array(
+ 'GroupSync',
+ 'UserSync',
+ 'UserSession',
+ 'UserProfile',
)
);
@@ -113,8 +155,8 @@ class ClassProvider implements ServiceProviderInterface
return new OAuth2($c);
});
- $container['htmlConverter'] = function () {
- return new HtmlConverter(array('strip_tags' => true));
+ $container['httpClient'] = function ($c) {
+ return new HttpClient($c);
};
$container['objectStorage'] = function () {
@@ -129,22 +171,12 @@ class ClassProvider implements ServiceProviderInterface
return $mailer;
};
- $container['userNotificationType'] = function ($container) {
- $type = new UserNotificationType($container);
- $type->setType('email', t('Email'), '\Kanboard\Notification\Mail');
- $type->setType('web', t('Web'), '\Kanboard\Notification\Web');
- return $type;
- };
-
- $container['projectNotificationType'] = function ($container) {
- $type = new ProjectNotificationType($container);
- $type->setType('webhook', 'Webhook', '\Kanboard\Notification\Webhook', true);
- $type->setType('activity_stream', 'ActivityStream', '\Kanboard\Notification\ActivityStream', true);
- return $type;
- };
-
- $container['pluginLoader'] = new Loader($container);
+ $container['cspRules'] = array(
+ 'default-src' => "'self'",
+ 'style-src' => "'self' 'unsafe-inline'",
+ 'img-src' => '* data:',
+ );
- $container['cspRules'] = array('style-src' => "'self' 'unsafe-inline'", 'img-src' => '* data:');
+ return $container;
}
}
diff --git a/app/ServiceProvider/DatabaseProvider.php b/app/ServiceProvider/DatabaseProvider.php
index b2115644..8cede8af 100644
--- a/app/ServiceProvider/DatabaseProvider.php
+++ b/app/ServiceProvider/DatabaseProvider.php
@@ -15,6 +15,8 @@ class DatabaseProvider implements ServiceProviderInterface
$container['db'] = $this->getInstance();
$container['db']->stopwatch = DEBUG;
$container['db']->logQueries = DEBUG;
+
+ return $container;
}
/**
diff --git a/app/ServiceProvider/EventDispatcherProvider.php b/app/ServiceProvider/EventDispatcherProvider.php
index 1711919e..880caa41 100644
--- a/app/ServiceProvider/EventDispatcherProvider.php
+++ b/app/ServiceProvider/EventDispatcherProvider.php
@@ -11,7 +11,6 @@ use Kanboard\Subscriber\NotificationSubscriber;
use Kanboard\Subscriber\ProjectDailySummarySubscriber;
use Kanboard\Subscriber\ProjectModificationDateSubscriber;
use Kanboard\Subscriber\SubtaskTimeTrackingSubscriber;
-use Kanboard\Subscriber\TaskMovedDateSubscriber;
use Kanboard\Subscriber\TransitionSubscriber;
use Kanboard\Subscriber\RecurringTaskSubscriber;
@@ -26,11 +25,9 @@ class EventDispatcherProvider implements ServiceProviderInterface
$container['dispatcher']->addSubscriber(new ProjectModificationDateSubscriber($container));
$container['dispatcher']->addSubscriber(new NotificationSubscriber($container));
$container['dispatcher']->addSubscriber(new SubtaskTimeTrackingSubscriber($container));
- $container['dispatcher']->addSubscriber(new TaskMovedDateSubscriber($container));
$container['dispatcher']->addSubscriber(new TransitionSubscriber($container));
$container['dispatcher']->addSubscriber(new RecurringTaskSubscriber($container));
- // Automatic actions
- $container['action']->attachEvents();
+ return $container;
}
}
diff --git a/app/ServiceProvider/ExternalLinkProvider.php b/app/ServiceProvider/ExternalLinkProvider.php
new file mode 100644
index 00000000..c4bbc4cf
--- /dev/null
+++ b/app/ServiceProvider/ExternalLinkProvider.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\ExternalLink\ExternalLinkManager;
+use Kanboard\ExternalLink\WebLinkProvider;
+use Kanboard\ExternalLink\AttachmentLinkProvider;
+
+/**
+ * External Link Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class ExternalLinkProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['externalLinkManager'] = new ExternalLinkManager($container);
+ $container['externalLinkManager']->register(new WebLinkProvider($container));
+ $container['externalLinkManager']->register(new AttachmentLinkProvider($container));
+
+ return $container;
+ }
+}
diff --git a/app/ServiceProvider/GroupProvider.php b/app/ServiceProvider/GroupProvider.php
new file mode 100644
index 00000000..b222b218
--- /dev/null
+++ b/app/ServiceProvider/GroupProvider.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\Group\GroupManager;
+use Kanboard\Group\DatabaseBackendGroupProvider;
+use Kanboard\Group\LdapBackendGroupProvider;
+
+/**
+ * Group Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class GroupProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['groupManager'] = new GroupManager;
+
+ if (DB_GROUP_PROVIDER) {
+ $container['groupManager']->register(new DatabaseBackendGroupProvider($container));
+ }
+
+ if (LDAP_AUTH && LDAP_GROUP_PROVIDER) {
+ $container['groupManager']->register(new LdapBackendGroupProvider($container));
+ }
+
+ return $container;
+ }
+}
diff --git a/app/ServiceProvider/LoggingProvider.php b/app/ServiceProvider/LoggingProvider.php
index 4344bccc..68c074f0 100644
--- a/app/ServiceProvider/LoggingProvider.php
+++ b/app/ServiceProvider/LoggingProvider.php
@@ -26,5 +26,7 @@ class LoggingProvider implements ServiceProviderInterface
}
$container['logger'] = $logger;
+
+ return $container;
}
}
diff --git a/app/ServiceProvider/NotificationProvider.php b/app/ServiceProvider/NotificationProvider.php
new file mode 100644
index 00000000..83daf65d
--- /dev/null
+++ b/app/ServiceProvider/NotificationProvider.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Model\UserNotificationType;
+use Kanboard\Model\ProjectNotificationType;
+use Kanboard\Notification\Mail as MailNotification;
+use Kanboard\Notification\Web as WebNotification;
+
+/**
+ * Notification Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class NotificationProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['userNotificationType'] = function ($container) {
+ $type = new UserNotificationType($container);
+ $type->setType(MailNotification::TYPE, t('Email'), '\Kanboard\Notification\Mail');
+ $type->setType(WebNotification::TYPE, t('Web'), '\Kanboard\Notification\Web');
+ return $type;
+ };
+
+ $container['projectNotificationType'] = function ($container) {
+ $type = new ProjectNotificationType($container);
+ $type->setType('webhook', 'Webhook', '\Kanboard\Notification\Webhook', true);
+ $type->setType('activity_stream', 'ActivityStream', '\Kanboard\Notification\ActivityStream', true);
+ return $type;
+ };
+
+ return $container;
+ }
+}
diff --git a/app/ServiceProvider/PluginProvider.php b/app/ServiceProvider/PluginProvider.php
new file mode 100644
index 00000000..d2f1666b
--- /dev/null
+++ b/app/ServiceProvider/PluginProvider.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\Plugin\Loader;
+
+/**
+ * Plugin Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class PluginProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['pluginLoader'] = new Loader($container);
+ $container['pluginLoader']->scan();
+
+ return $container;
+ }
+}
diff --git a/app/ServiceProvider/RouteProvider.php b/app/ServiceProvider/RouteProvider.php
new file mode 100644
index 00000000..d551f25d
--- /dev/null
+++ b/app/ServiceProvider/RouteProvider.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\Http\Route;
+use Kanboard\Core\Http\Router;
+
+/**
+ * Route Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class RouteProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['router'] = new Router($container);
+ $container['route'] = new Route($container);
+
+ if (ENABLE_URL_REWRITE) {
+ $container['route']->enable();
+
+ // Dashboard
+ $container['route']->addRoute('dashboard', 'app', 'index');
+ $container['route']->addRoute('dashboard/:user_id', 'app', 'index');
+ $container['route']->addRoute('dashboard/:user_id/projects', 'app', 'projects');
+ $container['route']->addRoute('dashboard/:user_id/tasks', 'app', 'tasks');
+ $container['route']->addRoute('dashboard/:user_id/subtasks', 'app', 'subtasks');
+ $container['route']->addRoute('dashboard/:user_id/calendar', 'app', 'calendar');
+ $container['route']->addRoute('dashboard/:user_id/activity', 'app', 'activity');
+ $container['route']->addRoute('dashboard/:user_id/notifications', 'app', 'notifications');
+
+ // Search routes
+ $container['route']->addRoute('search', 'search', 'index');
+ $container['route']->addRoute('search/:search', 'search', 'index');
+
+ // ProjectCreation routes
+ $container['route']->addRoute('project/create', 'ProjectCreation', 'create');
+ $container['route']->addRoute('project/create/private', 'ProjectCreation', 'createPrivate');
+
+ // Project routes
+ $container['route']->addRoute('projects', 'project', 'index');
+ $container['route']->addRoute('project/:project_id', 'project', 'show');
+ $container['route']->addRoute('p/:project_id', 'project', 'show');
+ $container['route']->addRoute('project/:project_id/customer-filters', 'customfilter', 'index');
+ $container['route']->addRoute('project/:project_id/share', 'project', 'share');
+ $container['route']->addRoute('project/:project_id/notifications', 'project', 'notifications');
+ $container['route']->addRoute('project/:project_id/integrations', 'project', 'integrations');
+ $container['route']->addRoute('project/:project_id/duplicate', 'project', 'duplicate');
+ $container['route']->addRoute('project/:project_id/remove', 'project', 'remove');
+ $container['route']->addRoute('project/:project_id/disable', 'project', 'disable');
+ $container['route']->addRoute('project/:project_id/enable', 'project', 'enable');
+ $container['route']->addRoute('project/:project_id/permissions', 'ProjectPermission', 'index');
+ $container['route']->addRoute('project/:project_id/import', 'taskImport', 'step1');
+
+ // Project Overview
+ $container['route']->addRoute('project/:project_id/overview', 'ProjectOverview', 'show');
+
+ // ProjectEdit routes
+ $container['route']->addRoute('project/:project_id/edit', 'ProjectEdit', 'edit');
+ $container['route']->addRoute('project/:project_id/edit/dates', 'ProjectEdit', 'dates');
+ $container['route']->addRoute('project/:project_id/edit/description', 'ProjectEdit', 'description');
+ $container['route']->addRoute('project/:project_id/edit/priority', 'ProjectEdit', 'priority');
+
+ // ProjectUser routes
+ $container['route']->addRoute('projects/managers/:user_id', 'projectuser', 'managers');
+ $container['route']->addRoute('projects/members/:user_id', 'projectuser', 'members');
+ $container['route']->addRoute('projects/tasks/:user_id/opens', 'projectuser', 'opens');
+ $container['route']->addRoute('projects/tasks/:user_id/closed', 'projectuser', 'closed');
+ $container['route']->addRoute('projects/managers', 'projectuser', 'managers');
+
+ // Action routes
+ $container['route']->addRoute('project/:project_id/actions', 'action', 'index');
+
+ // Column routes
+ $container['route']->addRoute('project/:project_id/columns', 'column', 'index');
+
+ // Swimlane routes
+ $container['route']->addRoute('project/:project_id/swimlanes', 'swimlane', 'index');
+
+ // Category routes
+ $container['route']->addRoute('project/:project_id/categories', 'category', 'index');
+
+ // Task routes
+ $container['route']->addRoute('project/:project_id/task/:task_id', 'task', 'show');
+ $container['route']->addRoute('t/:task_id', 'task', 'show');
+ $container['route']->addRoute('public/task/:task_id/:token', 'task', 'readonly');
+
+ $container['route']->addRoute('project/:project_id/task/:task_id/activity', 'activity', 'task');
+ $container['route']->addRoute('project/:project_id/task/:task_id/transitions', 'task', 'transitions');
+ $container['route']->addRoute('project/:project_id/task/:task_id/analytics', 'task', 'analytics');
+ $container['route']->addRoute('project/:project_id/task/:task_id/subtasks', 'subtask', 'show');
+ $container['route']->addRoute('project/:project_id/task/:task_id/time-tracking', 'task', 'timetracking');
+ $container['route']->addRoute('project/:project_id/task/:task_id/internal/links', 'tasklink', 'show');
+ $container['route']->addRoute('project/:project_id/task/:task_id/external/links', 'TaskExternalLink', 'show');
+
+ // Exports
+ $container['route']->addRoute('export/tasks/:project_id', 'export', 'tasks');
+ $container['route']->addRoute('export/subtasks/:project_id', 'export', 'subtasks');
+ $container['route']->addRoute('export/transitions/:project_id', 'export', 'transitions');
+ $container['route']->addRoute('export/summary/:project_id', 'export', 'summary');
+
+ // Analytics routes
+ $container['route']->addRoute('analytics/tasks/:project_id', 'analytic', 'tasks');
+ $container['route']->addRoute('analytics/users/:project_id', 'analytic', 'users');
+ $container['route']->addRoute('analytics/cfd/:project_id', 'analytic', 'cfd');
+ $container['route']->addRoute('analytics/burndown/:project_id', 'analytic', 'burndown');
+ $container['route']->addRoute('analytics/average-time-column/:project_id', 'analytic', 'averageTimeByColumn');
+ $container['route']->addRoute('analytics/lead-cycle-time/:project_id', 'analytic', 'leadAndCycleTime');
+ $container['route']->addRoute('analytics/estimated-spent-time/:project_id', 'analytic', 'compareHours');
+
+ // Board routes
+ $container['route']->addRoute('board/:project_id', 'board', 'show');
+ $container['route']->addRoute('b/:project_id', 'board', 'show');
+ $container['route']->addRoute('public/board/:token', 'board', 'readonly');
+
+ // Calendar routes
+ $container['route']->addRoute('calendar/:project_id', 'calendar', 'show');
+ $container['route']->addRoute('c/:project_id', 'calendar', 'show');
+
+ // Listing routes
+ $container['route']->addRoute('list/:project_id', 'listing', 'show');
+ $container['route']->addRoute('l/:project_id', 'listing', 'show');
+
+ // Gantt routes
+ $container['route']->addRoute('gantt/:project_id', 'gantt', 'project');
+ $container['route']->addRoute('gantt/:project_id/sort/:sorting', 'gantt', 'project');
+
+ // Feed routes
+ $container['route']->addRoute('feed/project/:token', 'feed', 'project');
+ $container['route']->addRoute('feed/user/:token', 'feed', 'user');
+
+ // Ical routes
+ $container['route']->addRoute('ical/project/:token', 'ical', 'project');
+ $container['route']->addRoute('ical/user/:token', 'ical', 'user');
+
+ // Users
+ $container['route']->addRoute('users', 'user', 'index');
+ $container['route']->addRoute('user/profile/:user_id', 'user', 'profile');
+ $container['route']->addRoute('user/show/:user_id', 'user', 'show');
+ $container['route']->addRoute('user/show/:user_id/timesheet', 'user', 'timesheet');
+ $container['route']->addRoute('user/show/:user_id/last-logins', 'user', 'last');
+ $container['route']->addRoute('user/show/:user_id/sessions', 'user', 'sessions');
+ $container['route']->addRoute('user/:user_id/edit', 'user', 'edit');
+ $container['route']->addRoute('user/:user_id/password', 'user', 'password');
+ $container['route']->addRoute('user/:user_id/share', 'user', 'share');
+ $container['route']->addRoute('user/:user_id/notifications', 'user', 'notifications');
+ $container['route']->addRoute('user/:user_id/accounts', 'user', 'external');
+ $container['route']->addRoute('user/:user_id/integrations', 'user', 'integrations');
+ $container['route']->addRoute('user/:user_id/authentication', 'user', 'authentication');
+ $container['route']->addRoute('user/:user_id/2fa', 'twofactor', 'index');
+
+ // Groups
+ $container['route']->addRoute('groups', 'group', 'index');
+ $container['route']->addRoute('groups/create', 'group', 'create');
+ $container['route']->addRoute('group/:group_id/associate', 'group', 'associate');
+ $container['route']->addRoute('group/:group_id/dissociate/:user_id', 'group', 'dissociate');
+ $container['route']->addRoute('group/:group_id/edit', 'group', 'edit');
+ $container['route']->addRoute('group/:group_id/members', 'group', 'users');
+ $container['route']->addRoute('group/:group_id/remove', 'group', 'confirm');
+
+ // Config
+ $container['route']->addRoute('settings', 'config', 'index');
+ $container['route']->addRoute('settings/plugins', 'config', 'plugins');
+ $container['route']->addRoute('settings/application', 'config', 'application');
+ $container['route']->addRoute('settings/project', 'config', 'project');
+ $container['route']->addRoute('settings/project', 'config', 'project');
+ $container['route']->addRoute('settings/board', 'config', 'board');
+ $container['route']->addRoute('settings/calendar', 'config', 'calendar');
+ $container['route']->addRoute('settings/integrations', 'config', 'integrations');
+ $container['route']->addRoute('settings/webhook', 'config', 'webhook');
+ $container['route']->addRoute('settings/api', 'config', 'api');
+ $container['route']->addRoute('settings/links', 'link', 'index');
+ $container['route']->addRoute('settings/currencies', 'currency', 'index');
+
+ // Doc
+ $container['route']->addRoute('documentation/:file', 'doc', 'show');
+ $container['route']->addRoute('documentation', 'doc', 'show');
+
+ // Auth routes
+ $container['route']->addRoute('login', 'auth', 'login');
+ $container['route']->addRoute('logout', 'auth', 'logout');
+
+ // PasswordReset
+ $container['route']->addRoute('forgot-password', 'PasswordReset', 'create');
+ $container['route']->addRoute('forgot-password/change/:token', 'PasswordReset', 'change');
+ }
+
+ return $container;
+ }
+}
diff --git a/app/ServiceProvider/SessionProvider.php b/app/ServiceProvider/SessionProvider.php
new file mode 100644
index 00000000..0999d531
--- /dev/null
+++ b/app/ServiceProvider/SessionProvider.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Kanboard\ServiceProvider;
+
+use Pimple\Container;
+use Pimple\ServiceProviderInterface;
+use Kanboard\Core\Session\SessionManager;
+use Kanboard\Core\Session\SessionStorage;
+use Kanboard\Core\Session\FlashMessage;
+
+/**
+ * Session Provider
+ *
+ * @package serviceProvider
+ * @author Frederic Guillot
+ */
+class SessionProvider implements ServiceProviderInterface
+{
+ /**
+ * Register providers
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ * @return \Pimple\Container
+ */
+ public function register(Container $container)
+ {
+ $container['sessionStorage'] = function() {
+ return new SessionStorage;
+ };
+
+ $container['sessionManager'] = function($c) {
+ return new SessionManager($c);
+ };
+
+ $container['flash'] = function($c) {
+ return new FlashMessage($c);
+ };
+
+ return $container;
+ }
+}
diff --git a/app/Subscriber/AuthSubscriber.php b/app/Subscriber/AuthSubscriber.php
index 2461b52c..e839385f 100644
--- a/app/Subscriber/AuthSubscriber.php
+++ b/app/Subscriber/AuthSubscriber.php
@@ -2,26 +2,105 @@
namespace Kanboard\Subscriber;
-use Kanboard\Core\Request;
-use Kanboard\Event\AuthEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Kanboard\Core\Security\AuthenticationManager;
+use Kanboard\Core\Session\SessionManager;
+use Kanboard\Event\AuthSuccessEvent;
+use Kanboard\Event\AuthFailureEvent;
-class AuthSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+/**
+ * Authentication Subscriber
+ *
+ * @package subscriber
+ * @author Frederic Guillot
+ */
+class AuthSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
+ /**
+ * Get event listeners
+ *
+ * @static
+ * @access public
+ * @return array
+ */
public static function getSubscribedEvents()
{
return array(
- 'auth.success' => array('onSuccess', 0),
+ AuthenticationManager::EVENT_SUCCESS => 'afterLogin',
+ AuthenticationManager::EVENT_FAILURE => 'onLoginFailure',
+ SessionManager::EVENT_DESTROY => 'afterLogout',
);
}
- public function onSuccess(AuthEvent $event)
+ /**
+ * After Login callback
+ *
+ * @access public
+ * @param AuthSuccessEvent $event
+ */
+ public function afterLogin(AuthSuccessEvent $event)
{
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+
+ $userAgent = $this->request->getUserAgent();
+ $ipAddress = $this->request->getIpAddress();
+
+ $this->userLocking->resetFailedLogin($this->userSession->getUsername());
+
$this->lastLogin->create(
$event->getAuthType(),
- $event->getUserId(),
- Request::getIpAddress(),
- Request::getUserAgent()
+ $this->userSession->getId(),
+ $ipAddress,
+ $userAgent
);
+
+ if ($event->getAuthType() === 'RememberMe') {
+ $this->userSession->validatePostAuthentication();
+ }
+
+ if (isset($this->sessionStorage->hasRememberMe) && $this->sessionStorage->hasRememberMe) {
+ $session = $this->rememberMeSession->create($this->userSession->getId(), $ipAddress, $userAgent);
+ $this->rememberMeCookie->write($session['token'], $session['sequence'], $session['expiration']);
+ }
+ }
+
+ /**
+ * Destroy RememberMe session on logout
+ *
+ * @access public
+ */
+ public function afterLogout()
+ {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+ $credentials = $this->rememberMeCookie->read();
+
+ if ($credentials !== false) {
+ $session = $this->rememberMeSession->find($credentials['token'], $credentials['sequence']);
+
+ if (! empty($session)) {
+ $this->rememberMeSession->remove($session['id']);
+ }
+
+ $this->rememberMeCookie->remove();
+ }
+ }
+
+ /**
+ * Increment failed login counter
+ *
+ * @access public
+ */
+ public function onLoginFailure(AuthFailureEvent $event)
+ {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+ $username = $event->getUsername();
+
+ if (! empty($username)) {
+ $this->userLocking->incrementFailedLogin($username);
+
+ if ($this->userLocking->getFailedLogin($username) > BRUTEFORCE_LOCKDOWN) {
+ $this->userLocking->lock($username, BRUTEFORCE_LOCKDOWN_DURATION);
+ }
+ }
}
}
diff --git a/app/Subscriber/BaseSubscriber.php b/app/Subscriber/BaseSubscriber.php
new file mode 100644
index 00000000..2e41da76
--- /dev/null
+++ b/app/Subscriber/BaseSubscriber.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Kanboard\Subscriber;
+
+use Kanboard\Core\Base;
+
+/**
+ * Base class for subscribers
+ *
+ * @package subscriber
+ * @author Frederic Guillot
+ */
+class BaseSubscriber extends Base
+{
+ /**
+ * Method called
+ *
+ * @access private
+ * @var array
+ */
+ private $called = array();
+
+ /**
+ * Check if a listener has been executed
+ *
+ * @access public
+ * @param string $key
+ * @return boolean
+ */
+ public function isExecuted($key = '')
+ {
+ if (isset($this->called[$key])) {
+ return true;
+ }
+
+ $this->called[$key] = true;
+
+ return false;
+ }
+}
diff --git a/app/Subscriber/BootstrapSubscriber.php b/app/Subscriber/BootstrapSubscriber.php
index 25b919f7..ef0215f3 100644
--- a/app/Subscriber/BootstrapSubscriber.php
+++ b/app/Subscriber/BootstrapSubscriber.php
@@ -4,20 +4,39 @@ namespace Kanboard\Subscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-class BootstrapSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class BootstrapSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- 'session.bootstrap' => array('setup', 0),
- 'api.bootstrap' => array('setup', 0),
- 'console.bootstrap' => array('setup', 0),
+ 'app.bootstrap' => 'execute',
);
}
- public function setup()
+ public function execute()
{
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
$this->config->setupTranslations();
$this->config->setupTimezone();
+ $this->actionManager->attachEvents();
+
+ if ($this->userSession->isLogged()) {
+ $this->sessionStorage->hasSubtaskInProgress = $this->subtask->hasSubtaskInProgress($this->userSession->getId());
+ }
+ }
+
+ public function __destruct()
+ {
+ if (DEBUG) {
+ foreach ($this->db->getLogMessages() as $message) {
+ $this->logger->debug($message);
+ }
+
+ $this->logger->debug('SQL_QUERIES={nb}', array('nb' => $this->container['db']->nbQueries));
+ $this->logger->debug('RENDERING={time}', array('time' => microtime(true) - $this->request->getStartTime()));
+ $this->logger->debug('MEMORY='.$this->helper->text->bytes(memory_get_usage()));
+ $this->logger->debug('URI='.$this->request->getUri());
+ $this->logger->debug('###############################################');
+ }
}
}
diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php
index 394573e4..651b8a96 100644
--- a/app/Subscriber/NotificationSubscriber.php
+++ b/app/Subscriber/NotificationSubscriber.php
@@ -6,37 +6,46 @@ use Kanboard\Event\GenericEvent;
use Kanboard\Model\Task;
use Kanboard\Model\Comment;
use Kanboard\Model\Subtask;
-use Kanboard\Model\File;
+use Kanboard\Model\TaskFile;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-class NotificationSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class NotificationSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- Task::EVENT_CREATE => array('execute', 0),
- Task::EVENT_UPDATE => array('execute', 0),
- Task::EVENT_CLOSE => array('execute', 0),
- 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),
- Comment::EVENT_CREATE => array('execute', 0),
- Comment::EVENT_UPDATE => array('execute', 0),
- File::EVENT_CREATE => array('execute', 0),
+ Task::EVENT_USER_MENTION => 'handleEvent',
+ Task::EVENT_CREATE => 'handleEvent',
+ Task::EVENT_UPDATE => 'handleEvent',
+ Task::EVENT_CLOSE => 'handleEvent',
+ Task::EVENT_OPEN => 'handleEvent',
+ Task::EVENT_MOVE_COLUMN => 'handleEvent',
+ Task::EVENT_MOVE_POSITION => 'handleEvent',
+ Task::EVENT_MOVE_SWIMLANE => 'handleEvent',
+ Task::EVENT_ASSIGNEE_CHANGE => 'handleEvent',
+ Subtask::EVENT_CREATE => 'handleEvent',
+ Subtask::EVENT_UPDATE => 'handleEvent',
+ Comment::EVENT_CREATE => 'handleEvent',
+ Comment::EVENT_UPDATE => 'handleEvent',
+ Comment::EVENT_USER_MENTION => 'handleEvent',
+ TaskFile::EVENT_CREATE => 'handleEvent',
);
}
- public function execute(GenericEvent $event, $event_name)
+ public function handleEvent(GenericEvent $event, $event_name)
{
- $event_data = $this->getEventData($event);
+ if (! $this->isExecuted($event_name)) {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+ $event_data = $this->getEventData($event);
- if (! empty($event_data)) {
- $this->userNotification->sendNotifications($event_name, $event_data);
- $this->projectNotification->sendNotifications($event_data['task']['project_id'], $event_name, $event_data);
+ if (! empty($event_data)) {
+ if (! empty($event['mention'])) {
+ $this->userNotification->sendUserNotification($event['mention'], $event_name, $event_data);
+ } else {
+ $this->userNotification->sendNotifications($event_name, $event_data);
+ $this->projectNotification->sendNotifications($event_data['task']['project_id'], $event_name, $event_data);
+ }
+ }
}
}
diff --git a/app/Subscriber/ProjectDailySummarySubscriber.php b/app/Subscriber/ProjectDailySummarySubscriber.php
index bfa6cd42..44138f43 100644
--- a/app/Subscriber/ProjectDailySummarySubscriber.php
+++ b/app/Subscriber/ProjectDailySummarySubscriber.php
@@ -6,22 +6,23 @@ use Kanboard\Event\TaskEvent;
use Kanboard\Model\Task;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-class ProjectDailySummarySubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class ProjectDailySummarySubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- Task::EVENT_CREATE => array('execute', 0),
- Task::EVENT_UPDATE => array('execute', 0),
- Task::EVENT_CLOSE => array('execute', 0),
- Task::EVENT_OPEN => array('execute', 0),
- Task::EVENT_MOVE_COLUMN => array('execute', 0),
+ Task::EVENT_CREATE_UPDATE => 'execute',
+ Task::EVENT_CLOSE => 'execute',
+ Task::EVENT_OPEN => 'execute',
+ Task::EVENT_MOVE_COLUMN => 'execute',
+ Task::EVENT_MOVE_SWIMLANE => 'execute',
);
}
public function execute(TaskEvent $event)
{
- if (isset($event['project_id'])) {
+ if (isset($event['project_id']) && !$this->isExecuted()) {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
$this->projectDailyColumnStats->updateTotals($event['project_id'], date('Y-m-d'));
$this->projectDailyStats->updateTotals($event['project_id'], date('Y-m-d'));
}
diff --git a/app/Subscriber/ProjectModificationDateSubscriber.php b/app/Subscriber/ProjectModificationDateSubscriber.php
index 1f99840d..62804a84 100644
--- a/app/Subscriber/ProjectModificationDateSubscriber.php
+++ b/app/Subscriber/ProjectModificationDateSubscriber.php
@@ -6,25 +6,26 @@ use Kanboard\Event\GenericEvent;
use Kanboard\Model\Task;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-class ProjectModificationDateSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class ProjectModificationDateSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- Task::EVENT_CREATE_UPDATE => array('execute', 0),
- Task::EVENT_CLOSE => array('execute', 0),
- Task::EVENT_OPEN => array('execute', 0),
- Task::EVENT_MOVE_SWIMLANE => array('execute', 0),
- Task::EVENT_MOVE_COLUMN => array('execute', 0),
- Task::EVENT_MOVE_POSITION => array('execute', 0),
- Task::EVENT_MOVE_PROJECT => array('execute', 0),
- Task::EVENT_ASSIGNEE_CHANGE => array('execute', 0),
+ Task::EVENT_CREATE_UPDATE => 'execute',
+ Task::EVENT_CLOSE => 'execute',
+ Task::EVENT_OPEN => 'execute',
+ Task::EVENT_MOVE_SWIMLANE => 'execute',
+ Task::EVENT_MOVE_COLUMN => 'execute',
+ Task::EVENT_MOVE_POSITION => 'execute',
+ Task::EVENT_MOVE_PROJECT => 'execute',
+ Task::EVENT_ASSIGNEE_CHANGE => 'execute',
);
}
public function execute(GenericEvent $event)
{
- if (isset($event['project_id'])) {
+ if (isset($event['project_id']) && !$this->isExecuted()) {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
$this->project->updateModificationDate($event['project_id']);
}
}
diff --git a/app/Subscriber/RecurringTaskSubscriber.php b/app/Subscriber/RecurringTaskSubscriber.php
index b03ffcf8..09a5665a 100644
--- a/app/Subscriber/RecurringTaskSubscriber.php
+++ b/app/Subscriber/RecurringTaskSubscriber.php
@@ -6,22 +6,24 @@ use Kanboard\Event\TaskEvent;
use Kanboard\Model\Task;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-class RecurringTaskSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class RecurringTaskSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- Task::EVENT_MOVE_COLUMN => array('onMove', 0),
- Task::EVENT_CLOSE => array('onClose', 0),
+ Task::EVENT_MOVE_COLUMN => 'onMove',
+ Task::EVENT_CLOSE => 'onClose',
);
}
public function onMove(TaskEvent $event)
{
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+
if ($event['recurrence_status'] == Task::RECURRING_STATUS_PENDING) {
- if ($event['recurrence_trigger'] == Task::RECURRING_TRIGGER_FIRST_COLUMN && $this->board->getFirstColumn($event['project_id']) == $event['src_column_id']) {
+ if ($event['recurrence_trigger'] == Task::RECURRING_TRIGGER_FIRST_COLUMN && $this->column->getFirstColumnId($event['project_id']) == $event['src_column_id']) {
$this->taskDuplication->duplicateRecurringTask($event['task_id']);
- } elseif ($event['recurrence_trigger'] == Task::RECURRING_TRIGGER_LAST_COLUMN && $this->board->getLastColumn($event['project_id']) == $event['dst_column_id']) {
+ } elseif ($event['recurrence_trigger'] == Task::RECURRING_TRIGGER_LAST_COLUMN && $this->column->getLastColumnId($event['project_id']) == $event['dst_column_id']) {
$this->taskDuplication->duplicateRecurringTask($event['task_id']);
}
}
@@ -29,6 +31,8 @@ class RecurringTaskSubscriber extends \Kanboard\Core\Base implements EventSubscr
public function onClose(TaskEvent $event)
{
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+
if ($event['recurrence_status'] == Task::RECURRING_STATUS_PENDING && $event['recurrence_trigger'] == Task::RECURRING_TRIGGER_CLOSE) {
$this->taskDuplication->duplicateRecurringTask($event['task_id']);
}
diff --git a/app/Subscriber/SubtaskTimeTrackingSubscriber.php b/app/Subscriber/SubtaskTimeTrackingSubscriber.php
index b5e0354d..c0852bc8 100644
--- a/app/Subscriber/SubtaskTimeTrackingSubscriber.php
+++ b/app/Subscriber/SubtaskTimeTrackingSubscriber.php
@@ -6,13 +6,13 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Kanboard\Model\Subtask;
use Kanboard\Event\SubtaskEvent;
-class SubtaskTimeTrackingSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class SubtaskTimeTrackingSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- Subtask::EVENT_CREATE => array('updateTaskTime', 0),
- Subtask::EVENT_DELETE => array('updateTaskTime', 0),
+ Subtask::EVENT_CREATE => 'updateTaskTime',
+ Subtask::EVENT_DELETE => 'updateTaskTime',
Subtask::EVENT_UPDATE => array(
array('logStartEnd', 10),
array('updateTaskTime', 0),
@@ -23,6 +23,7 @@ class SubtaskTimeTrackingSubscriber extends \Kanboard\Core\Base implements Event
public function updateTaskTime(SubtaskEvent $event)
{
if (isset($event['task_id'])) {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
$this->subtaskTimeTracking->updateTaskTimeTracking($event['task_id']);
}
}
@@ -30,6 +31,7 @@ class SubtaskTimeTrackingSubscriber extends \Kanboard\Core\Base implements Event
public function logStartEnd(SubtaskEvent $event)
{
if (isset($event['status']) && $this->config->get('subtask_time_tracking') == 1) {
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
$subtask = $this->subtask->getById($event['id']);
if (empty($subtask['user_id'])) {
diff --git a/app/Subscriber/TaskMovedDateSubscriber.php b/app/Subscriber/TaskMovedDateSubscriber.php
deleted file mode 100644
index 9857f4b3..00000000
--- a/app/Subscriber/TaskMovedDateSubscriber.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-namespace Kanboard\Subscriber;
-
-use Kanboard\Event\TaskEvent;
-use Kanboard\Model\Task;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-class TaskMovedDateSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
-{
- public static function getSubscribedEvents()
- {
- return array(
- Task::EVENT_MOVE_COLUMN => array('execute', 0),
- Task::EVENT_MOVE_SWIMLANE => array('execute', 0),
- );
- }
-
- public function execute(TaskEvent $event)
- {
- if (isset($event['task_id'])) {
- $this->container['db']->table(Task::TABLE)->eq('id', $event['task_id'])->update(array('date_moved' => time()));
- }
- }
-}
diff --git a/app/Subscriber/TransitionSubscriber.php b/app/Subscriber/TransitionSubscriber.php
index 35352dc7..bd537484 100644
--- a/app/Subscriber/TransitionSubscriber.php
+++ b/app/Subscriber/TransitionSubscriber.php
@@ -6,17 +6,19 @@ use Kanboard\Event\TaskEvent;
use Kanboard\Model\Task;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-class TransitionSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface
+class TransitionSubscriber extends BaseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
- Task::EVENT_MOVE_COLUMN => array('execute', 0),
+ Task::EVENT_MOVE_COLUMN => 'execute',
);
}
public function execute(TaskEvent $event)
{
+ $this->logger->debug('Subscriber executed: '.__METHOD__);
+
$user_id = $this->userSession->getId();
if (! empty($user_id)) {
diff --git a/app/Template/action/event.php b/app/Template/action/event.php
index 7f968a97..b4741a98 100644
--- a/app/Template/action/event.php
+++ b/app/Template/action/event.php
@@ -11,7 +11,7 @@
<?= $this->form->hidden('action_name', $values) ?>
<?= $this->form->label(t('Event'), 'event_name') ?>
- <?= $this->form->select('event_name', $events, $values) ?><br/>
+ <?= $this->form->select('event_name', $events, $values) ?>
<div class="form-help">
<?= t('When the selected event occurs execute the corresponding action.') ?>
diff --git a/app/Template/action/index.php b/app/Template/action/index.php
index bf2f7475..66cfed77 100644
--- a/app/Template/action/index.php
+++ b/app/Template/action/index.php
@@ -28,24 +28,24 @@
</td>
<td>
<ul>
- <?php foreach ($action['params'] as $param): ?>
+ <?php foreach ($action['params'] as $param_name => $param_value): ?>
<li>
- <?= $this->text->in($param['name'], $available_params) ?> =
+ <?= $this->text->in($param_name, $available_params[$action['action_name']]) ?> =
<strong>
- <?php if ($this->text->contains($param['name'], 'column_id')): ?>
- <?= $this->text->in($param['value'], $columns_list) ?>
- <?php elseif ($this->text->contains($param['name'], 'user_id')): ?>
- <?= $this->text->in($param['value'], $users_list) ?>
- <?php elseif ($this->text->contains($param['name'], 'project_id')): ?>
- <?= $this->text->in($param['value'], $projects_list) ?>
- <?php elseif ($this->text->contains($param['name'], 'color_id')): ?>
- <?= $this->text->in($param['value'], $colors_list) ?>
- <?php elseif ($this->text->contains($param['name'], 'category_id')): ?>
- <?= $this->text->in($param['value'], $categories_list) ?>
- <?php elseif ($this->text->contains($param['name'], 'link_id')): ?>
- <?= $this->text->in($param['value'], $links_list) ?>
+ <?php if ($this->text->contains($param_name, 'column_id')): ?>
+ <?= $this->text->in($param_value, $columns_list) ?>
+ <?php elseif ($this->text->contains($param_name, 'user_id')): ?>
+ <?= $this->text->in($param_value, $users_list) ?>
+ <?php elseif ($this->text->contains($param_name, 'project_id')): ?>
+ <?= $this->text->in($param_value, $projects_list) ?>
+ <?php elseif ($this->text->contains($param_name, 'color_id')): ?>
+ <?= $this->text->in($param_value, $colors_list) ?>
+ <?php elseif ($this->text->contains($param_name, 'category_id')): ?>
+ <?= $this->text->in($param_value, $categories_list) ?>
+ <?php elseif ($this->text->contains($param_name, 'link_id')): ?>
+ <?= $this->text->in($param_value, $links_list) ?>
<?php else: ?>
- <?= $this->e($param['value']) ?>
+ <?= $this->e($param_value) ?>
<?php endif ?>
</strong>
</li>
@@ -53,7 +53,7 @@
</ul>
</td>
<td>
- <?= $this->url->link(t('Remove'), 'action', 'confirm', array('project_id' => $project['id'], 'action_id' => $action['id'])) ?>
+ <?= $this->url->link(t('Remove'), 'action', 'confirm', array('project_id' => $project['id'], 'action_id' => $action['id']), false, 'popover') ?>
</td>
</tr>
<?php endforeach ?>
@@ -67,9 +67,9 @@
<?= $this->form->hidden('project_id', $values) ?>
<?= $this->form->label(t('Action'), 'action_name') ?>
- <?= $this->form->select('action_name', $available_actions, $values) ?><br/>
+ <?= $this->form->select('action_name', $available_actions, $values) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Next step') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Next step') ?>" class="btn btn-blue">
</div>
</form> \ No newline at end of file
diff --git a/app/Template/action/params.php b/app/Template/action/params.php
index dcfaa9cc..a2350dea 100644
--- a/app/Template/action/params.php
+++ b/app/Template/action/params.php
@@ -15,22 +15,25 @@
<?php if ($this->text->contains($param_name, 'column_id')): ?>
<?= $this->form->label($param_desc, $param_name) ?>
- <?= $this->form->select('params['.$param_name.']', $columns_list, $values) ?><br/>
+ <?= $this->form->select('params['.$param_name.']', $columns_list, $values) ?>
<?php elseif ($this->text->contains($param_name, 'user_id')): ?>
<?= $this->form->label($param_desc, $param_name) ?>
- <?= $this->form->select('params['.$param_name.']', $users_list, $values) ?><br/>
+ <?= $this->form->select('params['.$param_name.']', $users_list, $values) ?>
<?php elseif ($this->text->contains($param_name, 'project_id')): ?>
<?= $this->form->label($param_desc, $param_name) ?>
- <?= $this->form->select('params['.$param_name.']', $projects_list, $values) ?><br/>
+ <?= $this->form->select('params['.$param_name.']', $projects_list, $values) ?>
<?php elseif ($this->text->contains($param_name, 'color_id')): ?>
<?= $this->form->label($param_desc, $param_name) ?>
- <?= $this->form->select('params['.$param_name.']', $colors_list, $values) ?><br/>
+ <?= $this->form->select('params['.$param_name.']', $colors_list, $values) ?>
<?php elseif ($this->text->contains($param_name, 'category_id')): ?>
<?= $this->form->label($param_desc, $param_name) ?>
- <?= $this->form->select('params['.$param_name.']', $categories_list, $values) ?><br/>
+ <?= $this->form->select('params['.$param_name.']', $categories_list, $values) ?>
<?php elseif ($this->text->contains($param_name, 'link_id')): ?>
<?= $this->form->label($param_desc, $param_name) ?>
- <?= $this->form->select('params['.$param_name.']', $links_list, $values) ?><br/>
+ <?= $this->form->select('params['.$param_name.']', $links_list, $values) ?>
+ <?php elseif ($this->text->contains($param_name, 'duration')): ?>
+ <?= $this->form->label($param_desc, $param_name) ?>
+ <?= $this->form->number('params['.$param_name.']', $values) ?>
<?php else: ?>
<?= $this->form->label($param_desc, $param_name) ?>
<?= $this->form->text('params['.$param_name.']', $values) ?>
diff --git a/app/Template/action/remove.php b/app/Template/action/remove.php
index c8d4dfe4..070a7918 100644
--- a/app/Template/action/remove.php
+++ b/app/Template/action/remove.php
@@ -10,6 +10,6 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'action', 'remove', array('project_id' => $project['id'], 'action_id' => $action['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'action', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'action', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/activity/project.php b/app/Template/activity/project.php
index bc585212..ba6d6629 100644
--- a/app/Template/activity/project.php
+++ b/app/Template/activity/project.php
@@ -19,7 +19,7 @@
<i class="fa fa-calendar fa-fw"></i>
<?= $this->url->link(t('Back to the calendar'), 'calendar', 'show', array('project_id' => $project['id'])) ?>
</li>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
+ <?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $project['id'])): ?>
<li>
<i class="fa fa-cog fa-fw"></i>
<?= $this->url->link(t('Project settings'), 'project', 'show', array('project_id' => $project['id'])) ?>
diff --git a/app/Template/analytic/compare_hours.php b/app/Template/analytic/compare_hours.php
new file mode 100644
index 00000000..bb145d61
--- /dev/null
+++ b/app/Template/analytic/compare_hours.php
@@ -0,0 +1,62 @@
+<div class="page-header">
+ <h2><?= t('Compare Estimated Time vs Actual Time') ?></h2>
+</div>
+
+<div class="listing">
+ <ul>
+ <li><?= t('Estimated hours: ').'<strong>'.$this->e($metrics['open']['time_estimated'] + $metrics['closed']['time_estimated']) ?></strong></li>
+ <li><?= t('Actual hours: ').'<strong>'.$this->e($metrics['open']['time_spent'] + $metrics['closed']['time_spent']) ?></strong></li>
+ </ul>
+</div>
+
+<?php if (empty($metrics)): ?>
+ <p class="alert"><?= t('Not enough data to show the graph.') ?></p>
+<?php else: ?>
+<section id="analytic-compare-hours">
+ <div id="chart"
+ data-metrics='<?= json_encode($metrics, JSON_HEX_APOS)?>'
+ data-label-spent="<?= t('Hours Spent') ?>"
+ data-label-estimated="<?= t('Hours Estimated') ?>"
+ data-label-closed="<?= t('Closed') ?>"
+ data-label-open="<?= t('Open') ?>"></div>
+
+ <?php if ($paginator->isEmpty()): ?>
+ <p class="alert"><?= t('No tasks found.') ?></p>
+ <?php elseif (! $paginator->isEmpty()): ?>
+ <table class="table-fixed table-small">
+ <tr>
+ <th class="column-5"><?= $paginator->order(t('Id'), 'tasks.id') ?></th>
+ <th><?= $paginator->order(t('Title'), 'tasks.title') ?></th>
+ <th class="column-5"><?= $paginator->order(t('Status'), 'tasks.is_active') ?></th>
+ <th class="column-10"><?= $paginator->order(t('Estimated Time'), 'tasks.time_estimated') ?></th>
+ <th class="column-10"><?= $paginator->order(t('Actual Time'), 'tasks.time_spent') ?></th>
+ </tr>
+ <?php foreach ($paginator->getCollection() as $task): ?>
+ <tr>
+ <td class="task-table color-<?= $task['color_id'] ?>">
+ <?= $this->url->link('#'.$this->e($task['id']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, '', t('View this task')) ?>
+ </td>
+ <td>
+ <?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, '', t('View this task')) ?>
+ </td>
+ <td>
+ <?php if ($task['is_active'] == \Kanboard\Model\Task::STATUS_OPEN): ?>
+ <?= t('Open') ?>
+ <?php else: ?>
+ <?= t('Closed') ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?= $this->e($task['time_estimated']) ?>
+ </td>
+ <td>
+ <?= $this->e($task['time_spent']) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <?= $paginator ?>
+ <?php endif ?>
+</section>
+<?php endif ?>
diff --git a/app/Template/analytic/layout.php b/app/Template/analytic/layout.php
index fd2090ae..f1dba552 100644
--- a/app/Template/analytic/layout.php
+++ b/app/Template/analytic/layout.php
@@ -19,7 +19,7 @@
<i class="fa fa-calendar fa-fw"></i>
<?= $this->url->link(t('Back to the calendar'), 'calendar', 'show', array('project_id' => $project['id'])) ?>
</li>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
+ <?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $project['id'])): ?>
<li>
<i class="fa fa-cog fa-fw"></i>
<?= $this->url->link(t('Project settings'), 'project', 'show', array('project_id' => $project['id'])) ?>
@@ -31,9 +31,8 @@
</li>
</ul>
</div>
- <section class="sidebar-container" id="analytic-section">
-
- <?= $this->render('analytic/sidebar', array('project' => $project)) ?>
+ <section class="sidebar-container">
+ <?= $this->render($sidebar_template, array('project' => $project)) ?>
<div class="sidebar-content">
<?= $content_for_sublayout ?>
diff --git a/app/Template/analytic/sidebar.php b/app/Template/analytic/sidebar.php
index c942f7ed..76289b9f 100644
--- a/app/Template/analytic/sidebar.php
+++ b/app/Template/analytic/sidebar.php
@@ -1,25 +1,29 @@
<div class="sidebar">
<h2><?= t('Reportings') ?></h2>
<ul>
- <li <?= $this->app->getRouterAction() === 'tasks' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('analytic', 'tasks') ?>>
<?= $this->url->link(t('Task distribution'), 'analytic', 'tasks', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'users' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('analytic', 'users') ?>>
<?= $this->url->link(t('User repartition'), 'analytic', 'users', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'cfd' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('analytic', 'cfd') ?>>
<?= $this->url->link(t('Cumulative flow diagram'), 'analytic', 'cfd', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'burndown' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('analytic', 'burndown') ?>>
<?= $this->url->link(t('Burndown chart'), 'analytic', 'burndown', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'averagetimebycolumn' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('analytic', 'averageTimeByColumn') ?>>
<?= $this->url->link(t('Average time into each column'), 'analytic', 'averageTimeByColumn', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'leadandcycletime' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('analytic', 'leadAndCycleTime') ?>>
<?= $this->url->link(t('Lead and cycle time'), 'analytic', 'leadAndCycleTime', array('project_id' => $project['id'])) ?>
</li>
+ <li <?= $this->app->checkMenuSelection('analytic', 'compareHours') ?>>
+ <?= $this->url->link(t('Estimated vs actual time'), 'analytic', 'compareHours', array('project_id' => $project['id'])) ?>
+ </li>
+
+ <?= $this->hook->render('template:analytic:sidebar', array('project' => $project)) ?>
+
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
-</div> \ No newline at end of file
+</div>
diff --git a/app/Template/app/filters_helper.php b/app/Template/app/filters_helper.php
index 71b57a8c..c16c2251 100644
--- a/app/Template/app/filters_helper.php
+++ b/app/Template/app/filters_helper.php
@@ -1,5 +1,6 @@
-<div class="dropdown filters">
- <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Filters') ?></a>
+<?= $this->hook->render('template:app:filters-helper:before', isset($project) ? array('project' => $project) : array()) ?>
+<div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon" title="<?= t('Default filters') ?>"><i class="fa fa-filter fa-fw"></i><i class="fa fa-caret-down"></i></a>
<ul>
<li><a href="#" class="filter-helper filter-reset" data-filter="<?= isset($reset) ? $reset : '' ?>" title="<?= t('Keyboard shortcut: "%s"', 'r') ?>"><?= t('Reset filters') ?></a></li>
<li><a href="#" class="filter-helper" data-filter="status:open assignee:me"><?= t('My tasks') ?></a></li>
@@ -15,4 +16,5 @@
<?= $this->url->doc(t('View advanced search syntax'), 'search') ?>
</li>
</ul>
-</div> \ No newline at end of file
+</div>
+<?= $this->hook->render('template:app:filters-helper:after', isset($project) ? array('project' => $project) : array()) ?> \ No newline at end of file
diff --git a/app/Template/app/layout.php b/app/Template/app/layout.php
index 4f82121e..200cb0d7 100644
--- a/app/Template/app/layout.php
+++ b/app/Template/app/layout.php
@@ -1,16 +1,18 @@
<section id="main">
<div class="page-header page-header-mobile">
<ul>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('ProjectCreation', 'create')): ?>
<li>
<i class="fa fa-plus fa-fw"></i>
- <?= $this->url->link(t('New project'), 'project', 'create') ?>
+ <?= $this->url->link(t('New project'), 'ProjectCreation', 'create', array(), false, 'popover') ?>
</li>
<?php endif ?>
+ <?php if ($this->app->config('disable_private_project', 0) == 0): ?>
<li>
<i class="fa fa-lock fa-fw"></i>
- <?= $this->url->link(t('New private project'), 'project', 'create', array('private' => 1)) ?>
+ <?= $this->url->link(t('New private project'), 'ProjectCreation', 'createPrivate', array(), false, 'popover') ?>
</li>
+ <?php endif ?>
<li>
<i class="fa fa-search fa-fw"></i>
<?= $this->url->link(t('Search'), 'search', 'index') ?>
@@ -19,20 +21,10 @@
<i class="fa fa-folder fa-fw"></i>
<?= $this->url->link(t('Project management'), 'project', 'index') ?>
</li>
- <?php if ($this->user->isAdmin()): ?>
- <li>
- <i class="fa fa-user fa-fw"></i>
- <?= $this->url->link(t('User management'), 'user', 'index') ?>
- </li>
- <li>
- <i class="fa fa-cog fa-fw"></i>
- <?= $this->url->link(t('Settings'), 'config', 'index') ?>
- </li>
- <?php endif ?>
</ul>
</div>
<section class="sidebar-container" id="dashboard">
- <?= $this->render('app/sidebar', array('user' => $user)) ?>
+ <?= $this->render($sidebar_template, array('user' => $user)) ?>
<div class="sidebar-content">
<?= $content_for_sublayout ?>
</div>
diff --git a/app/Template/app/notifications.php b/app/Template/app/notifications.php
index 511f377b..4cb3c571 100644
--- a/app/Template/app/notifications.php
+++ b/app/Template/app/notifications.php
@@ -49,7 +49,7 @@
<?php endif ?>
</td>
<td>
- <?= dt('%B %e, %Y at %k:%M %p', $notification['date_creation']) ?>
+ <?= $this->dt->datetime($notification['date_creation']) ?>
</td>
<td>
<i class="fa fa-check fa-fw"></i>
diff --git a/app/Template/app/overview.php b/app/Template/app/overview.php
index 1b160496..0b354791 100644
--- a/app/Template/app/overview.php
+++ b/app/Template/app/overview.php
@@ -1,13 +1,12 @@
-<div class="search">
+<div class="filter-box">
<form method="get" action="<?= $this->url->dir() ?>" class="search">
<?= $this->form->hidden('controller', array('controller' => 'search')) ?>
<?= $this->form->hidden('action', array('action' => 'index')) ?>
<?= $this->form->text('search', array(), array(), array('placeholder="'.t('Search').'"'), 'form-input-large') ?>
+ <?= $this->render('app/filters_helper') ?>
</form>
-
- <?= $this->render('app/filters_helper') ?>
</div>
-<?= $this->render('app/projects', array('paginator' => $project_paginator)) ?>
-<?= $this->render('app/tasks', array('paginator' => $task_paginator)) ?>
-<?= $this->render('app/subtasks', array('paginator' => $subtask_paginator)) ?> \ No newline at end of file
+<?= $this->render('app/projects', array('paginator' => $project_paginator, 'user' => $user)) ?>
+<?= $this->render('app/tasks', array('paginator' => $task_paginator, 'user' => $user)) ?>
+<?= $this->render('app/subtasks', array('paginator' => $subtask_paginator, 'user' => $user)) ?> \ No newline at end of file
diff --git a/app/Template/app/projects.php b/app/Template/app/projects.php
index cf22707b..61899c96 100644
--- a/app/Template/app/projects.php
+++ b/app/Template/app/projects.php
@@ -1,5 +1,5 @@
<div class="page-header">
- <h2><?= t('My projects') ?> (<?= $paginator->getTotal() ?>)</h2>
+ <h2><?= $this->url->link(t('My projects'), 'app', 'projects', array('user_id' => $user['id'])) ?> (<?= $paginator->getTotal() ?>)</h2>
</div>
<?php if ($paginator->isEmpty()): ?>
<p class="alert"><?= t('Your are not member of any project.') ?></p>
@@ -22,7 +22,7 @@
<?php endif ?>
</td>
<td>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
+ <?php if ($this->user->hasProjectAccess('gantt', 'project', $project['id'])): ?>
<?= $this->url->link('<i class="fa fa-sliders fa-fw"></i>', 'gantt', 'project', array('project_id' => $project['id']), false, 'dashboard-table-link', t('Gantt chart')) ?>
<?php endif ?>
diff --git a/app/Template/app/sidebar.php b/app/Template/app/sidebar.php
index 552a9c27..3f0d988b 100644
--- a/app/Template/app/sidebar.php
+++ b/app/Template/app/sidebar.php
@@ -1,29 +1,27 @@
<div class="sidebar">
<h2><?= $this->e($user['name'] ?: $user['username']) ?></h2>
<ul>
- <li <?= $this->app->getRouterAction() === 'index' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'index') ?>>
<?= $this->url->link(t('Overview'), 'app', 'index', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'projects' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'projects') ?>>
<?= $this->url->link(t('My projects'), 'app', 'projects', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'tasks' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'tasks') ?>>
<?= $this->url->link(t('My tasks'), 'app', 'tasks', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'subtasks' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'subtasks') ?>>
<?= $this->url->link(t('My subtasks'), 'app', 'subtasks', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'calendar' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'calendar') ?>>
<?= $this->url->link(t('My calendar'), 'app', 'calendar', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'activity' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'activity') ?>>
<?= $this->url->link(t('My activity stream'), 'app', 'activity', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'notifications' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('app', 'notifications') ?>>
<?= $this->url->link(t('My notifications'), 'app', 'notifications', array('user_id' => $user['id'])) ?>
</li>
<?= $this->hook->render('template:dashboard:sidebar') ?>
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
</div> \ No newline at end of file
diff --git a/app/Template/app/subtasks.php b/app/Template/app/subtasks.php
index ad7402bd..6848112c 100644
--- a/app/Template/app/subtasks.php
+++ b/app/Template/app/subtasks.php
@@ -1,12 +1,12 @@
<div class="page-header">
- <h2><?= t('My subtasks') ?> (<?= $paginator->getTotal() ?>)</h2>
+ <h2><?= $this->url->link(t('My subtasks'), 'app', 'subtasks', array('user_id' => $user['id'])) ?> (<?= $paginator->getTotal() ?>)</h2>
</div>
<?php if ($paginator->isEmpty()): ?>
<p class="alert"><?= t('There is nothing assigned to you.') ?></p>
<?php else: ?>
<table class="table-fixed table-small">
<tr>
- <th class="column-10"><?= $paginator->order('Id', 'tasks.id') ?></th>
+ <th class="column-5"><?= $paginator->order('Id', 'tasks.id') ?></th>
<th class="column-20"><?= $paginator->order(t('Project'), 'project_name') ?></th>
<th><?= $paginator->order(t('Task'), 'task_name') ?></th>
<th><?= $paginator->order(t('Subtask'), 'title') ?></th>
@@ -15,7 +15,7 @@
<?php foreach ($paginator->getCollection() as $subtask): ?>
<tr>
<td class="task-table color-<?= $subtask['color_id'] ?>">
- <?= $this->url->link('#'.$subtask['task_id'], 'task', 'show', array('task_id' => $subtask['task_id'], 'project_id' => $subtask['project_id'])) ?>
+ <?= $this->render('task/dropdown', array('task' => array('id' => $subtask['task_id'], 'project_id' => $subtask['project_id']))) ?>
</td>
<td>
<?= $this->url->link($this->e($subtask['project_name']), 'board', 'show', array('project_id' => $subtask['project_id'])) ?>
@@ -24,7 +24,7 @@
<?= $this->url->link($this->e($subtask['task_name']), 'task', 'show', array('task_id' => $subtask['task_id'], 'project_id' => $subtask['project_id'])) ?>
</td>
<td>
- <?= $this->subtask->toggleStatus($subtask, 'dashboard') ?>
+ <?= $this->subtask->toggleStatus($subtask, $subtask['project_id']) ?>
</td>
<td>
<?php if (! empty($subtask['time_spent'])): ?>
diff --git a/app/Template/app/tasks.php b/app/Template/app/tasks.php
index 3712750b..786d7159 100644
--- a/app/Template/app/tasks.php
+++ b/app/Template/app/tasks.php
@@ -1,12 +1,12 @@
<div class="page-header">
- <h2><?= t('My tasks') ?> (<?= $paginator->getTotal() ?>)</h2>
+ <h2><?= $this->url->link(t('My tasks'), 'app', 'tasks', array('user_id' => $user['id'])) ?> (<?= $paginator->getTotal() ?>)</h2>
</div>
<?php if ($paginator->isEmpty()): ?>
<p class="alert"><?= t('There is nothing assigned to you.') ?></p>
<?php else: ?>
<table class="table-fixed table-small">
<tr>
- <th class="column-8"><?= $paginator->order('Id', 'tasks.id') ?></th>
+ <th class="column-5"><?= $paginator->order('Id', 'tasks.id') ?></th>
<th class="column-20"><?= $paginator->order(t('Project'), 'project_name') ?></th>
<th><?= $paginator->order(t('Task'), 'title') ?></th>
<th class="column-20"><?= t('Time tracking') ?></th>
@@ -15,7 +15,7 @@
<?php foreach ($paginator->getCollection() as $task): ?>
<tr>
<td class="task-table color-<?= $task['color_id'] ?>">
- <?= $this->url->link('#'.$task['id'], 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->render('task/dropdown', array('task' => $task)) ?>
</td>
<td>
<?= $this->url->link($this->e($task['project_name']), 'board', 'show', array('project_id' => $task['project_id'])) ?>
@@ -33,7 +33,7 @@
<?php endif ?>
</td>
<td>
- <?= dt('%B %e, %Y', $task['date_due']) ?>
+ <?= $this->dt->date($task['date_due']) ?>
</td>
</tr>
<?php endforeach ?>
diff --git a/app/Template/auth/index.php b/app/Template/auth/index.php
index 2f75b113..41441e69 100644
--- a/app/Template/auth/index.php
+++ b/app/Template/auth/index.php
@@ -19,36 +19,24 @@
<?php if (isset($captcha) && $captcha): ?>
<?= $this->form->label(t('Enter the text below'), 'captcha') ?>
- <img src="<?= $this->url->href('auth', 'captcha') ?>"/>
- <?= $this->form->text('captcha', $values, $errors, array('required')) ?>
+ <img src="<?= $this->url->href('Captcha', 'image') ?>"/>
+ <?= $this->form->text('captcha', array(), $errors, array('required')) ?>
<?php endif ?>
<?php if (REMEMBER_ME_AUTH): ?>
- <?= $this->form->checkbox('remember_me', t('Remember Me'), 1, true) ?><br/>
+ <?= $this->form->checkbox('remember_me', t('Remember Me'), 1, true) ?><br>
<?php endif ?>
<div class="form-actions">
<input type="submit" value="<?= t('Sign in') ?>" class="btn btn-blue"/>
</div>
+ <?php if ($this->app->config('password_reset') == 1): ?>
+ <div class="reset-password">
+ <?= $this->url->link(t('Forgot password?'), 'PasswordReset', 'create') ?>
+ </div>
+ <?php endif ?>
</form>
<?php endif ?>
<?= $this->hook->render('template:auth:login-form:after') ?>
-
- <?php if (GOOGLE_AUTH || GITHUB_AUTH || GITLAB_AUTH): ?>
- <ul class="no-bullet">
- <?php if (GOOGLE_AUTH): ?>
- <li><?= $this->url->link(t('Login with my Google Account'), 'oauth', 'google') ?></li>
- <?php endif ?>
-
- <?php if (GITHUB_AUTH): ?>
- <li><?= $this->url->link(t('Login with my Github Account'), 'oauth', 'github') ?></li>
- <?php endif ?>
-
- <?php if (GITLAB_AUTH): ?>
- <li><?= $this->url->link(t('Login with my Gitlab Account'), 'oauth', 'gitlab') ?></li>
- <?php endif ?>
- </ul>
- <?php endif ?>
-
</div> \ No newline at end of file
diff --git a/app/Template/board/popover_assignee.php b/app/Template/board/popover_assignee.php
index 4af19cf7..8db95323 100644
--- a/app/Template/board/popover_assignee.php
+++ b/app/Template/board/popover_assignee.php
@@ -1,21 +1,20 @@
<section id="main">
- <section>
- <h3><?= t('Change assignee for the task "%s"', $values['title']) ?></h3>
- <form method="post" action="<?= $this->url->href('board', 'updateAssignee', array('task_id' => $values['id'], 'project_id' => $values['project_id'])) ?>">
+ <div class="page-header">
+ <h2><?= t('Change assignee for the task "%s"', $values['title']) ?></h2>
+ </div>
+ <form method="post" action="<?= $this->url->href('BoardPopover', 'updateAssignee', array('task_id' => $values['id'], 'project_id' => $project['id'])) ?>">
- <?= $this->form->csrf() ?>
+ <?= $this->form->csrf() ?>
- <?= $this->form->hidden('id', $values) ?>
- <?= $this->form->hidden('project_id', $values) ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('project_id', $values) ?>
- <?= $this->form->label(t('Assignee'), 'owner_id') ?>
- <?= $this->form->select('owner_id', $users_list, $values, array(), array('autofocus')) ?><br/>
+ <?= $this->task->selectAssignee($users_list, $values, array(), array('autofocus')) ?>
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- <?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
- </div>
- </form>
- </section>
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+ </form>
</section> \ No newline at end of file
diff --git a/app/Template/board/popover_category.php b/app/Template/board/popover_category.php
index f391f492..e65593cd 100644
--- a/app/Template/board/popover_category.php
+++ b/app/Template/board/popover_category.php
@@ -1,21 +1,20 @@
<section id="main">
- <section>
- <h3><?= t('Change category for the task "%s"', $values['title']) ?></h3>
- <form method="post" action="<?= $this->url->href('board', 'updateCategory', array('task_id' => $values['id'], 'project_id' => $values['project_id'])) ?>">
+ <div class="page-header">
+ <h2><?= t('Change category for the task "%s"', $values['title']) ?></h2>
+ </div>
+ <form method="post" action="<?= $this->url->href('BoardPopover', 'updateCategory', array('task_id' => $values['id'], 'project_id' => $project['id'])) ?>">
- <?= $this->form->csrf() ?>
+ <?= $this->form->csrf() ?>
- <?= $this->form->hidden('id', $values) ?>
- <?= $this->form->hidden('project_id', $values) ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('project_id', $values) ?>
- <?= $this->form->label(t('Category'), 'category_id') ?>
- <?= $this->form->select('category_id', $categories_list, $values, array(), array('autofocus')) ?><br/>
+ <?= $this->task->selectCategory($categories_list, $values, array(), array('autofocus'), true) ?>
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- <?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
- </div>
- </form>
- </section>
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+ </form>
</section> \ No newline at end of file
diff --git a/app/Template/board/popover_close_all_tasks_column.php b/app/Template/board/popover_close_all_tasks_column.php
new file mode 100644
index 00000000..da6b9ad7
--- /dev/null
+++ b/app/Template/board/popover_close_all_tasks_column.php
@@ -0,0 +1,18 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Do you really want to close all tasks of this column?') ?></h2>
+ </div>
+ <form method="post" action="<?= $this->url->href('BoardPopover', 'closeColumnTasks', array('project_id' => $project['id'])) ?>">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('column_id', $values) ?>
+ <?= $this->form->hidden('swimlane_id', $values) ?>
+
+ <p class="alert"><?= t('%d task(s) in the column "%s" and the swimlane "%s" will be closed.', $nb_tasks, $column, $swimlane) ?></p>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-red">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+ </form>
+</section> \ No newline at end of file
diff --git a/app/Template/board/table_column.php b/app/Template/board/table_column.php
index 2a6b496a..58dcaf32 100644
--- a/app/Template/board/table_column.php
+++ b/app/Template/board/table_column.php
@@ -5,14 +5,14 @@
<!-- column in collapsed mode -->
<div class="board-column-collapsed">
- <span title="<?= t('Task count') ?>" class="board-column-header-task-count" title="<?= t('Show this column') ?>">
+ <span class="board-column-header-task-count" title="<?= t('Show this column') ?>">
<span id="task-number-column-<?= $column['id'] ?>"><?= $column['nb_tasks'] ?></span>
</span>
</div>
<!-- column in expanded mode -->
<div class="board-column-expanded">
- <?php if (! $not_editable): ?>
+ <?php if (! $not_editable && $this->user->hasProjectAccess('taskcreation', 'create', $column['project_id'])): ?>
<div class="board-add-icon">
<?= $this->url->link('+', 'taskcreation', 'create', array('project_id' => $column['project_id'], 'column_id' => $column['id'], 'swimlane_id' => $swimlane['id']), false, 'popover', t('Add a new task')) ?>
</div>
@@ -24,13 +24,31 @@
</span>
<?php endif ?>
- <span class="board-column-title" data-column-id="<?= $column['id'] ?>" title="<?= t('Hide this column') ?>">
- <?= $this->e($column['title']) ?>
+ <span class="board-column-title">
+ <?php if ($not_editable): ?>
+ <?= $this->e($column['title']) ?>
+ <?php else: ?>
+ <span class="dropdown">
+ <a href="#" class="dropdown-menu"><?= $this->e($column['title']) ?> <i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <i class="fa fa-minus-square fa-fw"></i>
+ <a href="#" class="board-toggle-column-view" data-column-id="<?= $column['id'] ?>"><?= t('Hide this column') ?></a>
+ </li>
+ <?php if ($this->user->hasProjectAccess('BoardPopover', 'closeColumnTasks', $column['project_id']) && $column['nb_tasks'] > 0): ?>
+ <li>
+ <i class="fa fa-close fa-fw"></i>
+ <?= $this->url->link(t('Close all tasks of this column'), 'BoardPopover', 'confirmCloseColumnTasks', array('project_id' => $column['project_id'], 'column_id' => $column['id'], 'swimlane_id' => $swimlane['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </span>
+ <?php endif ?>
</span>
<?php if (! $not_editable && ! empty($column['description'])): ?>
<span class="tooltip pull-right" title='<?= $this->e($this->text->markdown($column['description'])) ?>'>
- <i class="fa fa-info-circle"></i>
+ &nbsp;<i class="fa fa-info-circle"></i>
</span>
<?php endif ?>
diff --git a/app/Template/board/table_swimlane.php b/app/Template/board/table_swimlane.php
index dd38fc97..44607859 100644
--- a/app/Template/board/table_swimlane.php
+++ b/app/Template/board/table_swimlane.php
@@ -14,7 +14,7 @@
<span
title="<?= t('Description') ?>"
class="tooltip"
- data-href="<?= $this->url->href('board', 'swimlane', array('swimlane_id' => $swimlane['id'], 'project_id' => $project['id'])) ?>">
+ data-href="<?= $this->url->href('BoardTooltip', 'swimlane', array('swimlane_id' => $swimlane['id'], 'project_id' => $project['id'])) ?>">
<i class="fa fa-info-circle"></i>
</span>
<?php endif ?>
diff --git a/app/Template/board/table_tasks.php b/app/Template/board/table_tasks.php
index f10d48e4..e99e14fb 100644
--- a/app/Template/board/table_tasks.php
+++ b/app/Template/board/table_tasks.php
@@ -21,8 +21,8 @@
<!-- column in collapsed mode (rotated text) -->
<div class="board-column-collapsed">
<div class="board-rotation-wrapper">
- <div class="board-column-title board-rotation" data-column-id="<?= $column['id'] ?>" title="<?= t('Show this column') ?>">
- <i class="fa fa-chevron-circle-up tooltip" title="<?= $this->e($column['title']) ?>"></i> <?= $this->e($column['title']) ?>
+ <div class="board-column-title board-rotation board-toggle-column-view" data-column-id="<?= $column['id'] ?>" title="<?= t('Show this column') ?>">
+ <i class="fa fa-plus-square tooltip" title="<?= $this->e($column['title']) ?>"></i> <?= $this->e($column['title']) ?>
</div>
</div>
</div>
diff --git a/app/Template/board/task_footer.php b/app/Template/board/task_footer.php
index d486b638..73e68602 100644
--- a/app/Template/board/task_footer.php
+++ b/app/Template/board/task_footer.php
@@ -6,7 +6,7 @@
<?php else: ?>
<?= $this->url->link(
$this->e($task['category_name']),
- 'board',
+ 'boardPopover',
'changeCategory',
array('task_id' => $task['id'], 'project_id' => $task['project_id']),
false,
@@ -22,36 +22,40 @@
<?php if (! empty($task['date_due'])): ?>
<span class="task-board-date <?= time() > $task['date_due'] ? 'task-board-date-overdue' : '' ?>">
<i class="fa fa-calendar"></i>
- <?= (date('Y') === date('Y', $task['date_due']) ? dt('%b %e', $task['date_due']) : dt('%b %e %Y', $task['date_due'])) ?>
+ <?= $this->dt->date($task['date_due']) ?>
</span>
<?php endif ?>
<?php if ($task['recurrence_status'] == \Kanboard\Model\Task::RECURRING_STATUS_PENDING): ?>
- <span title="<?= t('Recurrence') ?>" class="tooltip" data-href="<?= $this->url->href('board', 'recurrence', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-refresh fa-rotate-90"></i></span>
+ <span title="<?= t('Recurrence') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'recurrence', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-refresh fa-rotate-90"></i></span>
<?php endif ?>
<?php if ($task['recurrence_status'] == \Kanboard\Model\Task::RECURRING_STATUS_PROCESSED): ?>
- <span title="<?= t('Recurrence') ?>" class="tooltip" data-href="<?= $this->url->href('board', 'recurrence', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-refresh fa-rotate-90 fa-inverse"></i></span>
+ <span title="<?= t('Recurrence') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'recurrence', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-refresh fa-rotate-90 fa-inverse"></i></span>
<?php endif ?>
<?php if (! empty($task['nb_links'])): ?>
- <span title="<?= t('Links') ?>" class="tooltip" data-href="<?= $this->url->href('board', 'tasklinks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-code-fork"></i>&nbsp;<?= $task['nb_links'] ?></span>
+ <span title="<?= t('Links') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'tasklinks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-code-fork fa-fw"></i><?= $task['nb_links'] ?></span>
+ <?php endif ?>
+
+ <?php if (! empty($task['nb_external_links'])): ?>
+ <span title="<?= t('External links') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'externallinks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-external-link fa-fw"></i><?= $task['nb_external_links'] ?></span>
<?php endif ?>
<?php if (! empty($task['nb_subtasks'])): ?>
- <span title="<?= t('Sub-Tasks') ?>" class="tooltip" data-href="<?= $this->url->href('board', 'subtasks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-bars"></i>&nbsp;<?= round($task['nb_completed_subtasks']/$task['nb_subtasks']*100, 0).'%' ?></span>
+ <span title="<?= t('Sub-Tasks') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'subtasks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-bars"></i>&nbsp;<?= round($task['nb_completed_subtasks']/$task['nb_subtasks']*100, 0).'%' ?></span>
<?php endif ?>
<?php if (! empty($task['nb_files'])): ?>
- <span title="<?= t('Attachments') ?>" class="tooltip" data-href="<?= $this->url->href('board', 'attachments', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-paperclip"></i>&nbsp;<?= $task['nb_files'] ?></span>
+ <span title="<?= t('Attachments') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'attachments', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-paperclip"></i>&nbsp;<?= $task['nb_files'] ?></span>
<?php endif ?>
<?php if (! empty($task['nb_comments'])): ?>
- <span title="<?= $task['nb_comments'] == 1 ? t('%d comment', $task['nb_comments']) : t('%d comments', $task['nb_comments']) ?>" class="tooltip" data-href="<?= $this->url->href('board', 'comments', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-comment-o"></i>&nbsp;<?= $task['nb_comments'] ?></span>
+ <span title="<?= $task['nb_comments'] == 1 ? t('%d comment', $task['nb_comments']) : t('%d comments', $task['nb_comments']) ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'comments', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><i class="fa fa-comment-o"></i>&nbsp;<?= $task['nb_comments'] ?></span>
<?php endif ?>
<?php if (! empty($task['description'])): ?>
- <span title="<?= t('Description') ?>" class="tooltip" data-href="<?= $this->url->href('board', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>">
+ <span title="<?= t('Description') ?>" class="tooltip" data-href="<?= $this->url->href('BoardTooltip', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>">
<i class="fa fa-file-text-o"></i>
</span>
<?php endif ?>
@@ -69,4 +73,6 @@
<i class="fa fa-flag flag-milestone"></i>
</span>
<?php endif ?>
+
+ <?= $this->task->formatPriority($project, $task) ?>
</div>
diff --git a/app/Template/board/task_menu.php b/app/Template/board/task_menu.php
index 3eb35705..bd582185 100644
--- a/app/Template/board/task_menu.php
+++ b/app/Template/board/task_menu.php
@@ -1,17 +1,18 @@
<span class="dropdown">
<a href="#" class="dropdown-menu"><?= '#'.$task['id'] ?></a>
<ul>
- <li><i class="fa fa-user fa-fw"></i>&nbsp;<?= $this->url->link(t('Change assignee'), 'board', 'changeAssignee', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
- <li><i class="fa fa-tag fa-fw"></i>&nbsp;<?= $this->url->link(t('Change category'), 'board', 'changeCategory', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
+ <li><i class="fa fa-user fa-fw"></i>&nbsp;<?= $this->url->link(t('Change assignee'), 'BoardPopover', 'changeAssignee', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
+ <li><i class="fa fa-tag fa-fw"></i>&nbsp;<?= $this->url->link(t('Change category'), 'BoardPopover', 'changeCategory', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<li><i class="fa fa-align-left fa-fw"></i>&nbsp;<?= $this->url->link(t('Change description'), 'taskmodification', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<li><i class="fa fa-pencil-square-o fa-fw"></i>&nbsp;<?= $this->url->link(t('Edit this task'), 'taskmodification', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<li><i class="fa fa-comment-o fa-fw"></i>&nbsp;<?= $this->url->link(t('Add a comment'), 'comment', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<li><i class="fa fa-code-fork fa-fw"></i>&nbsp;<?= $this->url->link(t('Add a link'), 'tasklink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
- <li><i class="fa fa-camera fa-fw"></i>&nbsp;<?= $this->url->link(t('Add a screenshot'), 'board', 'screenshot', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
+ <li><i class="fa fa-external-link fa-fw"></i>&nbsp;<?= $this->url->link(t('Add external link'), 'TaskExternalLink', 'find', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
+ <li><i class="fa fa-camera fa-fw"></i>&nbsp;<?= $this->url->link(t('Add a screenshot'), 'BoardPopover', 'screenshot', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<?php if ($task['is_active'] == 1): ?>
- <li><i class="fa fa-close fa-fw"></i>&nbsp;<?= $this->url->link(t('Close this task'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'redirect' => 'board'), false, 'popover') ?></li>
+ <li><i class="fa fa-close fa-fw"></i>&nbsp;<?= $this->url->link(t('Close this task'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<?php else: ?>
- <li><i class="fa fa-check-square-o fa-fw"></i>&nbsp;<?= $this->url->link(t('Open this task'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'redirect' => 'board'), false, 'popover') ?></li>
+ <li><i class="fa fa-check-square-o fa-fw"></i>&nbsp;<?= $this->url->link(t('Open this task'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
<?php endif ?>
</ul>
</span> \ No newline at end of file
diff --git a/app/Template/board/task_private.php b/app/Template/board/task_private.php
index da993fdd..4880af00 100644
--- a/app/Template/board/task_private.php
+++ b/app/Template/board/task_private.php
@@ -1,8 +1,11 @@
<div class="
task-board
- <?= $task['is_active'] == 1 ? 'draggable-item task-board-status-open '.($task['date_modification'] > (time() - $board_highlight_period) ? 'task-board-recent' : '') : 'task-board-status-closed' ?>
+ <?= $task['is_active'] == 1 ? ($this->user->hasProjectAccess('board', 'save', $task['project_id']) ? 'draggable-item ' : '').'task-board-status-open '.($task['date_modification'] > (time() - $board_highlight_period) ? 'task-board-recent' : '') : 'task-board-status-closed' ?>
color-<?= $task['color_id'] ?>"
data-task-id="<?= $task['id'] ?>"
+ data-column-id="<?= $task['column_id'] ?>"
+ data-swimlane-id="<?= $task['swimlane_id'] ?>"
+ data-position="<?= $task['position'] ?>"
data-owner-id="<?= $task['owner_id'] ?>"
data-category-id="<?= $task['category_id'] ?>"
data-due-date="<?= $task['date_due'] ?>"
@@ -12,7 +15,12 @@
<?php if ($this->board->isCollapsed($task['project_id'])): ?>
<div class="task-board-collapsed">
- <?= $this->render('board/task_menu', array('task' => $task)) ?>
+ <div class="task-board-saving-icon" style="display: none;"><i class="fa fa-spinner fa-pulse"></i></div>
+ <?php if ($this->user->hasProjectAccess('taskmodification', 'edit', $task['project_id'])): ?>
+ <?= $this->render('board/task_menu', array('task' => $task)) ?>
+ <?php else: ?>
+ <strong><?= '#'.$task['id'] ?></strong>
+ <?php endif ?>
<?php if (! empty($task['assignee_username'])): ?>
<span title="<?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>">
@@ -23,7 +31,12 @@
</div>
<?php else: ?>
<div class="task-board-expanded">
- <?= $this->render('board/task_menu', array('task' => $task)) ?>
+ <div class="task-board-saving-icon" style="display: none;"><i class="fa fa-spinner fa-pulse fa-2x"></i></div>
+ <?php if ($this->user->hasProjectAccess('taskmodification', 'edit', $task['project_id'])): ?>
+ <?= $this->render('board/task_menu', array('task' => $task)) ?>
+ <?php else: ?>
+ <strong><?= '#'.$task['id'] ?></strong>
+ <?php endif ?>
<?php if ($task['reference']): ?>
<span class="task-board-reference" title="<?= t('Reference') ?>">
@@ -33,15 +46,19 @@
<?php if (! empty($task['owner_id'])): ?>
<span class="task-board-user <?= $this->user->isCurrentUser($task['owner_id']) ? 'task-board-current-user' : '' ?>">
- <?= $this->url->link(
- $task['assignee_name'] ?: $task['assignee_username'],
- 'board',
- 'changeAssignee',
- array('task_id' => $task['id'], 'project_id' => $task['project_id']),
- false,
- 'popover',
- t('Change assignee')
- ) ?>
+ <?php if ($this->user->hasProjectAccess('taskmodification', 'edit', $task['project_id'])): ?>
+ <?= $this->url->link(
+ $task['assignee_name'] ?: $task['assignee_username'],
+ 'BoardPopover',
+ 'changeAssignee',
+ array('task_id' => $task['id'], 'project_id' => $task['project_id']),
+ false,
+ 'popover',
+ t('Change assignee')
+ ) ?>
+ <?php else: ?>
+ <?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>
+ <?php endif ?>
</span>
<?php endif ?>
@@ -61,6 +78,7 @@
<?= $this->render('board/task_footer', array(
'task' => $task,
'not_editable' => $not_editable,
+ 'project' => $project,
)) ?>
</div>
<?php endif ?>
diff --git a/app/Template/board/task_public.php b/app/Template/board/task_public.php
index bacdcef4..d02722bb 100644
--- a/app/Template/board/task_public.php
+++ b/app/Template/board/task_public.php
@@ -25,5 +25,6 @@
<?= $this->render('board/task_footer', array(
'task' => $task,
'not_editable' => $not_editable,
+ 'project' => $project,
)) ?>
</div> \ No newline at end of file
diff --git a/app/Template/board/tooltip_comments.php b/app/Template/board/tooltip_comments.php
index 2e2c0c1e..ca91e13f 100644
--- a/app/Template/board/tooltip_comments.php
+++ b/app/Template/board/tooltip_comments.php
@@ -4,7 +4,7 @@
<?php if (! empty($comment['username'])): ?>
<span class="comment-username"><?= $this->e($comment['name'] ?: $comment['username']) ?></span> @
<?php endif ?>
- <span class="comment-date"><?= dt('%b %e, %Y, %k:%M %p', $comment['date_creation']) ?></span>
+ <span class="comment-date"><?= $this->dt->datetime($comment['date_creation']) ?></span>
</p>
<div class="comment-inner">
diff --git a/app/Template/board/tooltip_external_links.php b/app/Template/board/tooltip_external_links.php
new file mode 100644
index 00000000..7681c06c
--- /dev/null
+++ b/app/Template/board/tooltip_external_links.php
@@ -0,0 +1,20 @@
+<table class="table-striped table-small">
+ <tr>
+ <th class="column-20"><?= t('Type') ?></th>
+ <th class="column-80"><?= t('Title') ?></th>
+ <th class="column-10"><?= t('Dependency') ?></th>
+ </tr>
+ <?php foreach ($links as $link): ?>
+ <tr>
+ <td>
+ <?= $link['type'] ?>
+ </td>
+ <td>
+ <a href="<?= $link['url'] ?>" target="_blank"><?= $this->e($link['title']) ?></a>
+ </td>
+ <td>
+ <?= $this->e($link['dependency_label']) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+</table> \ No newline at end of file
diff --git a/app/Template/board/tooltip_files.php b/app/Template/board/tooltip_files.php
index 407309b3..4fa14b57 100644
--- a/app/Template/board/tooltip_files.php
+++ b/app/Template/board/tooltip_files.php
@@ -8,9 +8,9 @@
</tr>
<tr>
<td>
- <i class="fa fa-download fa-fw"></i><?= $this->url->link(t('download'), 'file', 'download', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
+ <i class="fa fa-download fa-fw"></i><?= $this->url->link(t('download'), 'TaskFile', 'download', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
<?php if ($file['is_image'] == 1): ?>
- &nbsp;<i class="fa fa-eye"></i> <?= $this->url->link(t('open file'), 'file', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), false, 'popover') ?>
+ &nbsp;<i class="fa fa-eye"></i> <?= $this->url->link(t('open file'), 'TaskFile', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), false, 'popover') ?>
<?php endif ?>
</td>
</tr>
diff --git a/app/Template/board/tooltip_subtasks.php b/app/Template/board/tooltip_subtasks.php
index 950da925..dc076d26 100644
--- a/app/Template/board/tooltip_subtasks.php
+++ b/app/Template/board/tooltip_subtasks.php
@@ -1,7 +1,14 @@
-<section id="tooltip-subtasks">
+<table class="table-stripped">
<?php foreach ($subtasks as $subtask): ?>
- <?= $this->subtask->toggleStatus($subtask, 'board') ?>
- <?= $this->e(empty($subtask['username']) ? '' : ' ['.$this->user->getFullname($subtask).']') ?>
- <br/>
+ <tr>
+ <td class="column-80">
+ <?= $this->subtask->toggleStatus($subtask, $task['project_id']) ?>
+ </td>
+ <td>
+ <?php if (! empty($subtask['username'])): ?>
+ <?= $this->e($subtask['name'] ?: $subtask['username']) ?>
+ <?php endif ?>
+ </td>
+ </tr>
<?php endforeach ?>
-</section>
+</table>
diff --git a/app/Template/board/tooltip_tasklinks.php b/app/Template/board/tooltip_tasklinks.php
index 62304330..b51f90ed 100644
--- a/app/Template/board/tooltip_tasklinks.php
+++ b/app/Template/board/tooltip_tasklinks.php
@@ -1,19 +1,24 @@
<div class="tooltip-tasklinks">
- <ul>
- <?php foreach ($links as $link): ?>
- <li>
- <strong><?= t($link['label']) ?></strong>
- [<i><?= $link['project_name'] ?></i>]
- <?= $this->url->link(
- $this->e('#'.$link['task_id'].' - '.$link['title']),
- 'task', 'show', array('task_id' => $link['task_id'], 'project_id' => $link['project_id']),
- false,
- $link['is_active'] ? '' : 'task-link-closed'
- ) ?>
- <?php if (! empty($link['task_assignee_username'])): ?>
- [<?= $this->e($link['task_assignee_name'] ?: $link['task_assignee_username']) ?>]
- <?php endif ?>
- </li>
+ <dl>
+ <?php foreach ($links as $label => $grouped_links): ?>
+ <dt><strong><?= t($label) ?></strong></dt>
+ <?php foreach ($grouped_links as $link): ?>
+ <dd>
+ <span class="progress"><?= $this->task->getProgress($link).'%' ?></span>
+ <?= $this->url->link(
+ $this->e('#'.$link['task_id'].' '.$link['title']),
+ 'task', 'show', array('task_id' => $link['task_id'], 'project_id' => $link['project_id']),
+ false,
+ $link['is_active'] ? '' : 'task-link-closed'
+ ) ?>
+ <?php if (! empty($link['task_assignee_username'])): ?>
+ [<?= $this->e($link['task_assignee_name'] ?: $link['task_assignee_username']) ?>]
+ <?php endif ?>
+ <?php if ($task['project_id'] != $link['project_id']): ?>
+ (<i><?= $link['project_name'] ?></i>)
+ <?php endif ?>
+ </dd>
+ <?php endforeach ?>
<?php endforeach ?>
- </ul>
+ </dl>
</div> \ No newline at end of file
diff --git a/app/Template/board/view_private.php b/app/Template/board/view_private.php
index 63d261f6..b5e38c66 100644
--- a/app/Template/board/view_private.php
+++ b/app/Template/board/view_private.php
@@ -1,6 +1,6 @@
<section id="main">
- <?= $this->render('project/filters', array(
+ <?= $this->render('project_header/header', array(
'project' => $project,
'filters' => $filters,
'categories_list' => $categories_list,
diff --git a/app/Template/calendar/show.php b/app/Template/calendar/show.php
index 0406414c..7085b51e 100644
--- a/app/Template/calendar/show.php
+++ b/app/Template/calendar/show.php
@@ -1,11 +1,11 @@
<section id="main">
- <?= $this->render('project/filters', array(
+ <?= $this->render('project_header/header', array(
'project' => $project,
'filters' => $filters,
)) ?>
<div id="calendar"
- data-save-url="<?= $this->url->href('calendar', 'save') ?>"
+ data-save-url="<?= $this->url->href('calendar', 'save', array('project_id' => $project['id'])) ?>"
data-check-url="<?= $this->url->href('calendar', 'project', array('project_id' => $project['id'])) ?>"
data-check-interval="<?= $check_interval ?>"
>
diff --git a/app/Template/category/edit.php b/app/Template/category/edit.php
index 1aae2f2a..d788f673 100644
--- a/app/Template/category/edit.php
+++ b/app/Template/category/edit.php
@@ -2,7 +2,7 @@
<h2><?= t('Category modification for the project "%s"', $project['name']) ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('category', 'update', array('project_id' => $project['id'], 'category_id' => $values['id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('category', 'update', array('project_id' => $project['id'], 'category_id' => $values['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -33,8 +33,8 @@
<div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></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">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'category', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'category', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/category/index.php b/app/Template/category/index.php
index dba537d0..e99b6d52 100644
--- a/app/Template/category/index.php
+++ b/app/Template/category/index.php
@@ -5,20 +5,23 @@
<table>
<tr>
<th><?= t('Category Name') ?></th>
- <th><?= t('Actions') ?></th>
+ <th class="column-8"><?= t('Actions') ?></th>
</tr>
<?php foreach ($categories as $category_id => $category_name): ?>
<tr>
<td><?= $this->e($category_name) ?></td>
<td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
<ul>
<li>
- <?= $this->url->link(t('Edit'), 'category', 'edit', array('project_id' => $project['id'], 'category_id' => $category_id)) ?>
+ <?= $this->url->link(t('Edit'), 'category', 'edit', array('project_id' => $project['id'], 'category_id' => $category_id), false, 'popover') ?>
</li>
<li>
- <?= $this->url->link(t('Remove'), 'category', 'confirm', array('project_id' => $project['id'], 'category_id' => $category_id)) ?>
+ <?= $this->url->link(t('Remove'), 'category', 'confirm', array('project_id' => $project['id'], 'category_id' => $category_id), false, 'popover') ?>
</li>
</ul>
+ </div>
</td>
</tr>
<?php endforeach ?>
diff --git a/app/Template/category/remove.php b/app/Template/category/remove.php
index ce589785..cad58d37 100644
--- a/app/Template/category/remove.php
+++ b/app/Template/category/remove.php
@@ -11,7 +11,7 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'category', 'remove', array('project_id' => $project['id'], 'category_id' => $category['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'category', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'category', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</div>
</section> \ No newline at end of file
diff --git a/app/Template/column/create.php b/app/Template/column/create.php
new file mode 100644
index 00000000..58b130f5
--- /dev/null
+++ b/app/Template/column/create.php
@@ -0,0 +1,41 @@
+<div class="page-header">
+ <h2><?= t('Add a new column') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('Column', 'save', array('project_id' => $project['id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('project_id', $values) ?>
+
+ <?= $this->form->label(t('Title'), 'title') ?>
+ <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
+
+ <?= $this->form->label(t('Task limit'), 'task_limit') ?>
+ <?= $this->form->number('task_limit', $values, $errors) ?>
+
+ <?= $this->form->label(t('Description'), 'description') ?>
+
+ <div class="form-tabs">
+ <div class="write-area">
+ <?= $this->form->textarea('description', $values, $errors) ?>
+ </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>
+ </li>
+ <li class="form-tab">
+ <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
+ </li>
+ </ul>
+ </div>
+ <div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Add this column') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'column', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/column/edit.php b/app/Template/column/edit.php
index a17affd8..618cb134 100644
--- a/app/Template/column/edit.php
+++ b/app/Template/column/edit.php
@@ -2,7 +2,7 @@
<h2><?= t('Edit column "%s"', $column['title']) ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('column', 'update', array('project_id' => $project['id'], 'column_id' => $column['id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('column', 'update', array('project_id' => $project['id'], 'column_id' => $column['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -37,8 +37,8 @@
<div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></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">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'column', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'column', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/column/index.php b/app/Template/column/index.php
index 689cbbf5..1aa44688 100644
--- a/app/Template/column/index.php
+++ b/app/Template/column/index.php
@@ -1,89 +1,56 @@
<div class="page-header">
<h2><?= t('Edit the board for "%s"', $project['name']) ?></h2>
+ <ul>
+ <li>
+ <i class="fa fa-plus fa-fw"></i>
+ <?= $this->url->link(t('Add a new column'), 'Column', 'create', array('project_id' => $project['id']), false, 'popover') ?>
+ </li>
+ </ul>
</div>
-<?php if (! empty($columns)): ?>
-
- <?php $first_position = $columns[0]['position']; ?>
- <?php $last_position = $columns[count($columns) - 1]['position']; ?>
-
- <h3><?= t('Change columns') ?></h3>
- <table>
+<?php if (empty($columns)): ?>
+ <p class="alert alert-error"><?= t('Your board doesn\'t have any column!') ?></p>
+<?php else: ?>
+ <table
+ class="columns-table table-stripped"
+ data-save-position-url="<?= $this->url->href('Column', 'move', array('project_id' => $project['id'])) ?>">
+ <thead>
<tr>
- <th><?= t('Column title') ?></th>
- <th><?= t('Task limit') ?></th>
- <th><?= t('Actions') ?></th>
+ <th class="column-70"><?= t('Column title') ?></th>
+ <th class="column-25"><?= t('Task limit') ?></th>
+ <th class="column-5"><?= t('Actions') ?></th>
</tr>
+ </thead>
+ <tbody>
<?php foreach ($columns as $column): ?>
- <tr>
- <td class="column-60"><?= $this->e($column['title']) ?>
- <?php if (! empty($column['description'])): ?>
- <span class="tooltip" title='<?= $this->e($this->text->markdown($column['description'])) ?>'>
- <i class="fa fa-info-circle"></i>
- </span>
- <?php endif ?>
+ <tr data-column-id="<?= $column['id'] ?>">
+ <td>
+ <i class="fa fa-arrows-alt draggable-row-handle" title="<?= t('Change column position') ?>"></i>
+ <?= $this->e($column['title']) ?>
+ <?php if (! empty($column['description'])): ?>
+ <span class="tooltip" title='<?= $this->e($this->text->markdown($column['description'])) ?>'>
+ <i class="fa fa-info-circle"></i>
+ </span>
+ <?php endif ?>
</td>
- <td class="column-10"><?= $this->e($column['task_limit']) ?></td>
- <td class="column-30">
+ <td>
+ <?= $this->e($column['task_limit']) ?>
+ </td>
+ <td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
<ul>
<li>
- <?= $this->url->link(t('Edit'), 'column', 'edit', array('project_id' => $project['id'], 'column_id' => $column['id'])) ?>
+ <?= $this->url->link(t('Edit'), 'column', 'edit', array('project_id' => $project['id'], 'column_id' => $column['id']), false, 'popover') ?>
</li>
- <?php if ($column['position'] != $first_position): ?>
<li>
- <?= $this->url->link(t('Move Up'), 'column', 'move', array('project_id' => $project['id'], 'column_id' => $column['id'], 'direction' => 'up'), true) ?>
- </li>
- <?php endif ?>
- <?php if ($column['position'] != $last_position): ?>
- <li>
- <?= $this->url->link(t('Move Down'), 'column', 'move', array('project_id' => $project['id'], 'column_id' => $column['id'], 'direction' => 'down'), true) ?>
- </li>
- <?php endif ?>
- <li>
- <?= $this->url->link(t('Remove'), 'column', 'confirm', array('project_id' => $project['id'], 'column_id' => $column['id'])) ?>
+ <?= $this->url->link(t('Remove'), 'column', 'confirm', array('project_id' => $project['id'], 'column_id' => $column['id']), false, 'popover') ?>
</li>
</ul>
+ </div>
</td>
</tr>
<?php endforeach ?>
+ </tbody>
</table>
-
<?php endif ?>
-
-<h3><?= t('Add a new column') ?></h3>
-<form method="post" action="<?= $this->url->href('column', 'create', array('project_id' => $project['id'])) ?>" autocomplete="off">
-
- <?= $this->form->csrf() ?>
-
- <?= $this->form->hidden('project_id', $values) ?>
-
- <?= $this->form->label(t('Title'), 'title') ?>
- <?= $this->form->text('title', $values, $errors, array('required', 'maxlength="50"')) ?>
-
- <?= $this->form->label(t('Task limit'), 'task_limit') ?>
- <?= $this->form->number('task_limit', $values, $errors) ?>
-
- <?= $this->form->label(t('Description'), 'description') ?>
-
- <div class="form-tabs">
- <div class="write-area">
- <?= $this->form->textarea('description', $values, $errors) ?>
- </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>
- </li>
- <li class="form-tab">
- <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
- </li>
- </ul>
- </div>
- <div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Add this column') ?>" class="btn btn-blue"/>
- </div>
-</form> \ No newline at end of file
diff --git a/app/Template/column/remove.php b/app/Template/column/remove.php
index 28d0928f..ccab889d 100644
--- a/app/Template/column/remove.php
+++ b/app/Template/column/remove.php
@@ -10,6 +10,6 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'column', 'remove', array('project_id' => $project['id'], 'column_id' => $column['id'], 'remove' => 'yes'), true, 'btn btn-red') ?>
- <?= t('or') ?> <?= $this->url->link(t('cancel'), 'column', 'index', array('project_id' => $project['id'])) ?>
+ <?= t('or') ?> <?= $this->url->link(t('cancel'), 'column', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/comment/create.php b/app/Template/comment/create.php
index 8bcbe0f7..15dd3a8e 100644
--- a/app/Template/comment/create.php
+++ b/app/Template/comment/create.php
@@ -1,8 +1,7 @@
<div class="page-header">
<h2><?= t('Add a comment') ?></h2>
</div>
-
-<form method="post" action="<?= $this->url->href('comment', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'ajax' => isset($ajax))) ?>" autocomplete="off" class="form-comment">
+<form class="popover-form" method="post" action="<?= $this->url->href('comment', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off" class="form-comment">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('task_id', $values) ?>
<?= $this->form->hidden('user_id', $values) ?>
@@ -17,7 +16,18 @@
</li>
</ul>
<div class="write-area">
- <?= $this->form->textarea('comment', $values, $errors, array(! isset($skip_cancel) ? 'autofocus' : '', 'required', 'placeholder="'.t('Leave a comment').'"'), 'comment-textarea') ?>
+ <?= $this->form->textarea(
+ 'comment',
+ $values,
+ $errors,
+ array(
+ ! isset($skip_cancel) ? 'autofocus' : '',
+ 'required',
+ 'placeholder="'.t('Leave a comment').'"',
+ 'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $task['project_id'])).'"',
+ ),
+ 'comment-textarea'
+ ) ?>
</div>
<div class="preview-area">
<div class="markdown"></div>
@@ -30,11 +40,7 @@
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
<?php if (! isset($skip_cancel)): ?>
<?= t('or') ?>
- <?php if (isset($ajax)): ?>
- <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $task['project_id'])) ?>
- <?php else: ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- <?php endif ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
<?php endif ?>
</div>
</form>
diff --git a/app/Template/comment/edit.php b/app/Template/comment/edit.php
index e01f3da4..6db952cc 100644
--- a/app/Template/comment/edit.php
+++ b/app/Template/comment/edit.php
@@ -2,7 +2,7 @@
<h2><?= t('Edit a comment') ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('comment', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('comment', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('id', $values) ?>
@@ -29,8 +29,8 @@
<div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
<div class="form-actions">
- <input type="submit" value="<?= t('Update') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Update') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/comment/forbidden.php b/app/Template/comment/forbidden.php
deleted file mode 100644
index 1e306d45..00000000
--- a/app/Template/comment/forbidden.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<div class="page-header">
- <h2><?= t('Forbidden') ?></h2>
-</div>
-
-<p class="alert alert-error">
- <?= t('Only administrators or the creator of the comment can access to this page.') ?>
-</p> \ No newline at end of file
diff --git a/app/Template/comment/remove.php b/app/Template/comment/remove.php
index afc3346f..1b5004f4 100644
--- a/app/Template/comment/remove.php
+++ b/app/Template/comment/remove.php
@@ -12,6 +12,6 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'comment', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/comment/show.php b/app/Template/comment/show.php
index 84077668..2c9b426f 100644
--- a/app/Template/comment/show.php
+++ b/app/Template/comment/show.php
@@ -9,19 +9,19 @@
<span class="comment-username"><?= $this->e($comment['name'] ?: $comment['username']) ?></span> @
<?php endif ?>
- <span class="comment-date"><?= dt('%B %e, %Y at %k:%M %p', $comment['date_creation']) ?></span>
+ <span class="comment-date"><?= $this->dt->datetime($comment['date_creation']) ?></span>
</p>
<div class="comment-inner">
<?php if (! isset($preview)): ?>
<ul class="comment-actions">
<li><a href="#comment-<?= $comment['id'] ?>"><?= t('link') ?></a></li>
- <?php if ((! isset($not_editable) || ! $not_editable) && ($this->user->isAdmin() || $this->user->isCurrentUser($comment['user_id']))): ?>
+ <?php if ($editable && ($this->user->isAdmin() || $this->user->isCurrentUser($comment['user_id']))): ?>
<li>
- <?= $this->url->link(t('remove'), 'comment', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id'])) ?>
+ <?= $this->url->link(t('remove'), 'comment', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id']), false, 'popover') ?>
</li>
<li>
- <?= $this->url->link(t('edit'), 'comment', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id'])) ?>
+ <?= $this->url->link(t('edit'), 'comment', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'comment_id' => $comment['id']), false, 'popover') ?>
</li>
<?php endif ?>
</ul>
diff --git a/app/Template/config/about.php b/app/Template/config/about.php
index e0652a2f..f8534029 100644
--- a/app/Template/config/about.php
+++ b/app/Template/config/about.php
@@ -54,6 +54,7 @@
<div class="listing">
<h3><?= t('Board/Calendar/List view') ?></h3>
<ul>
+ <li><?= t('Switch to the project overview') ?> = <strong>v o</strong></li>
<li><?= t('Switch to the board view') ?> = <strong>v b</strong></li>
<li><?= t('Switch to the calendar view') ?> = <strong>v c</strong></li>
<li><?= t('Switch to the list view') ?> = <strong>v l</strong></li>
diff --git a/app/Template/config/application.php b/app/Template/config/application.php
index 7d4c811d..48fed7f0 100644
--- a/app/Template/config/application.php
+++ b/app/Template/config/application.php
@@ -1,30 +1,36 @@
<div class="page-header">
<h2><?= t('Application settings') ?></h2>
</div>
-<section>
<form method="post" action="<?= $this->url->href('config', 'application') ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Application URL'), 'application_url') ?>
- <?= $this->form->text('application_url', $values, $errors, array('placeholder="http://example.kanboard.net/"')) ?><br/>
- <p class="form-help"><?= t('Example: http://example.kanboard.net/ (used by email notifications)') ?></p>
+ <?= $this->form->text('application_url', $values, $errors, array('placeholder="http://example.kanboard.net/"')) ?>
+ <p class="form-help"><?= t('Example: http://example.kanboard.net/ (used to generate absolute URLs)') ?></p>
<?= $this->form->label(t('Language'), 'application_language') ?>
- <?= $this->form->select('application_language', $languages, $values, $errors) ?><br/>
+ <?= $this->form->select('application_language', $languages, $values, $errors) ?>
<?= $this->form->label(t('Timezone'), 'application_timezone') ?>
- <?= $this->form->select('application_timezone', $timezones, $values, $errors) ?><br/>
+ <?= $this->form->select('application_timezone', $timezones, $values, $errors) ?>
<?= $this->form->label(t('Date format'), 'application_date_format') ?>
- <?= $this->form->select('application_date_format', $date_formats, $values, $errors) ?><br/>
+ <?= $this->form->select('application_date_format', $date_formats, $values, $errors) ?>
<p class="form-help"><?= t('ISO format is always accepted, example: "%s" and "%s"', date('Y-m-d'), date('Y_m_d')) ?></p>
+ <?= $this->form->label(t('Date and time format'), 'application_datetime_format') ?>
+ <?= $this->form->select('application_datetime_format', $datetime_formats, $values, $errors) ?>
+
+ <?= $this->form->label(t('Time format'), 'application_time_format') ?>
+ <?= $this->form->select('application_time_format', $time_formats, $values, $errors) ?>
+
+ <?= $this->form->checkbox('password_reset', t('Enable "Forget Password"'), 1, $values['password_reset'] == 1) ?>
+
<?= $this->form->label(t('Custom Stylesheet'), 'application_stylesheet') ?>
- <?= $this->form->textarea('application_stylesheet', $values, $errors) ?><br/>
+ <?= $this->form->textarea('application_stylesheet', $values, $errors) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
</div>
</form>
-</section> \ No newline at end of file
diff --git a/app/Template/config/board.php b/app/Template/config/board.php
index 19a4bcd7..f787a931 100644
--- a/app/Template/config/board.php
+++ b/app/Template/config/board.php
@@ -1,25 +1,23 @@
<div class="page-header">
<h2><?= t('Board settings') ?></h2>
</div>
-<section>
<form method="post" action="<?= $this->url->href('config', 'board') ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Task highlight period'), 'board_highlight_period') ?>
- <?= $this->form->number('board_highlight_period', $values, $errors) ?><br/>
+ <?= $this->form->number('board_highlight_period', $values, $errors) ?>
<p class="form-help"><?= t('Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)') ?></p>
<?= $this->form->label(t('Refresh interval for public board'), 'board_public_refresh_interval') ?>
- <?= $this->form->number('board_public_refresh_interval', $values, $errors) ?><br/>
+ <?= $this->form->number('board_public_refresh_interval', $values, $errors) ?>
<p class="form-help"><?= t('Frequency in second (60 seconds by default)') ?></p>
<?= $this->form->label(t('Refresh interval for private board'), 'board_private_refresh_interval') ?>
- <?= $this->form->number('board_private_refresh_interval', $values, $errors) ?><br/>
+ <?= $this->form->number('board_private_refresh_interval', $values, $errors) ?>
<p class="form-help"><?= t('Frequency in second (0 to disable this feature, 10 seconds by default)') ?></p>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
</div>
</form>
-</section> \ No newline at end of file
diff --git a/app/Template/config/integrations.php b/app/Template/config/integrations.php
index bba85672..ced051f7 100644
--- a/app/Template/config/integrations.php
+++ b/app/Template/config/integrations.php
@@ -3,29 +3,9 @@
</div>
<form method="post" action="<?= $this->url->href('config', 'integrations') ?>" autocomplete="off">
-
<?= $this->form->csrf() ?>
-
<?= $this->hook->render('template:config:integrations', array('values' => $values)) ?>
- <h3><i class="fa fa-google"></i> <?= t('Google Authentication') ?></h3>
- <div class="listing">
- <input type="text" class="auto-select" readonly="readonly" value="<?= $this->url->href('oauth', 'google', array(), false, '', true) ?>"/><br/>
- <p class="form-help"><?= $this->url->doc(t('Help on Google authentication'), 'google-authentication') ?></p>
- </div>
-
- <h3><i class="fa fa-github"></i> <?= t('Github Authentication') ?></h3>
- <div class="listing">
- <input type="text" class="auto-select" readonly="readonly" value="<?= $this->url->href('oauth', 'github', array(), false, '', true) ?>"/><br/>
- <p class="form-help"><?= $this->url->doc(t('Help on Github authentication'), 'github-authentication') ?></p>
- </div>
-
- <h3><img src="<?= $this->url->dir() ?>assets/img/gitlab-icon.png"/>&nbsp;<?= t('Gitlab Authentication') ?></h3>
- <div class="listing">
- <input type="text" class="auto-select" readonly="readonly" value="<?= $this->url->href('oauth', 'gitlab', array(), false, '', true) ?>"/><br/>
- <p class="form-help"><?= $this->url->doc(t('Help on Gitlab authentication'), 'gitlab-authentication') ?></p>
- </div>
-
<h3><img src="<?= $this->url->dir() ?>assets/img/gravatar-icon.png"/>&nbsp;<?= t('Gravatar') ?></h3>
<div class="listing">
<?= $this->form->checkbox('integration_gravatar', t('Enable Gravatar images'), 1, $values['integration_gravatar'] == 1) ?>
diff --git a/app/Template/config/layout.php b/app/Template/config/layout.php
index 028f138c..f34caaab 100644
--- a/app/Template/config/layout.php
+++ b/app/Template/config/layout.php
@@ -1,10 +1,10 @@
<section id="main">
<section class="sidebar-container" id="config-section">
- <?= $this->render('config/sidebar') ?>
+ <?= $this->render($sidebar_template) ?>
<div class="sidebar-content">
- <?= $config_content_for_layout ?>
+ <?= $content_for_sublayout ?>
</div>
</section>
</section> \ No newline at end of file
diff --git a/app/Template/config/project.php b/app/Template/config/project.php
index c58a7bac..1d32a14f 100644
--- a/app/Template/config/project.php
+++ b/app/Template/config/project.php
@@ -1,7 +1,6 @@
<div class="page-header">
<h2><?= t('Project settings') ?></h2>
</div>
-<section>
<form method="post" action="<?= $this->url->href('config', 'project') ?>" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -10,19 +9,19 @@
<?= $this->form->select('default_color', $colors, $values, $errors) ?>
<?= $this->form->label(t('Default columns for new projects (Comma-separated)'), 'board_columns') ?>
- <?= $this->form->text('board_columns', $values, $errors) ?><br/>
+ <?= $this->form->text('board_columns', $values, $errors) ?>
<p class="form-help"><?= t('Default values are "%s"', $default_columns) ?></p>
<?= $this->form->label(t('Default categories for new projects (Comma-separated)'), 'project_categories') ?>
- <?= $this->form->text('project_categories', $values, $errors) ?><br/>
+ <?= $this->form->text('project_categories', $values, $errors) ?>
<p class="form-help"><?= t('Example: "Bug, Feature Request, Improvement"') ?></p>
+ <?= $this->form->checkbox('disable_private_project', t('Disable private projects'), 1, isset($values['disable_private_project']) && $values['disable_private_project'] == 1) ?>
<?= $this->form->checkbox('subtask_restriction', t('Allow only one subtask in progress at the same time for a user'), 1, $values['subtask_restriction'] == 1) ?>
<?= $this->form->checkbox('subtask_time_tracking', t('Trigger automatically subtask time tracking'), 1, $values['subtask_time_tracking'] == 1) ?>
<?= $this->form->checkbox('cfd_include_closed_tasks', t('Include closed tasks in the cumulative flow diagram'), 1, $values['cfd_include_closed_tasks'] == 1) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
</div>
</form>
-</section> \ No newline at end of file
diff --git a/app/Template/config/sidebar.php b/app/Template/config/sidebar.php
index 4195cde1..dd51bc74 100644
--- a/app/Template/config/sidebar.php
+++ b/app/Template/config/sidebar.php
@@ -1,44 +1,39 @@
<div class="sidebar">
<h2><?= t('Actions') ?></h2>
<ul>
- <li <?= $this->app->getRouterAction() === 'index' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'index') ?>>
<?= $this->url->link(t('About'), 'config', 'index') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'plugins' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'plugins') ?>>
<?= $this->url->link(t('Plugins'), 'config', 'plugins') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'application' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'application') ?>>
<?= $this->url->link(t('Application settings'), 'config', 'application') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'project' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'project') ?>>
<?= $this->url->link(t('Project settings'), 'config', 'project') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'board' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'board') ?>>
<?= $this->url->link(t('Board settings'), 'config', 'board') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'calendar' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'calendar') ?>>
<?= $this->url->link(t('Calendar settings'), 'config', 'calendar') ?>
</li>
- <li <?= $this->app->getRouterController() === 'link' && $this->app->getRouterAction() === 'index' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('link') ?>>
<?= $this->url->link(t('Link settings'), 'link', 'index') ?>
</li>
- <li <?= $this->app->getRouterController() === 'currency' && $this->app->getRouterAction() === 'index' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('currency', 'index') ?>>
<?= $this->url->link(t('Currency rates'), 'currency', 'index') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'integrations' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'integrations') ?>>
<?= $this->url->link(t('Integrations'), 'config', 'integrations') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'webhook' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'webhook') ?>>
<?= $this->url->link(t('Webhooks'), 'config', 'webhook') ?>
</li>
- <li <?= $this->app->getRouterAction() === 'api' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('config', 'api') ?>>
<?= $this->url->link(t('API'), 'config', 'api') ?>
</li>
- <li>
- <?= $this->url->link(t('Documentation'), 'doc', 'show') ?>
- </li>
<?= $this->hook->render('template:config:sidebar') ?>
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
</div> \ No newline at end of file
diff --git a/app/Template/currency/index.php b/app/Template/currency/index.php
index 1c78c47a..07b58a8b 100644
--- a/app/Template/currency/index.php
+++ b/app/Template/currency/index.php
@@ -29,10 +29,10 @@
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Reference currency'), 'application_currency') ?>
- <?= $this->form->select('application_currency', $currencies, $config_values, $errors) ?><br/>
+ <?= $this->form->select('application_currency', $currencies, $config_values, $errors) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
</div>
</form>
@@ -43,12 +43,12 @@
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Currency'), 'currency') ?>
- <?= $this->form->select('currency', $currencies, $values, $errors) ?><br/>
+ <?= $this->form->select('currency', $currencies, $values, $errors) ?>
<?= $this->form->label(t('Rate'), 'rate') ?>
- <?= $this->form->text('rate', $values, $errors, array(), 'form-numeric') ?><br/>
+ <?= $this->form->text('rate', $values, $errors, array(), 'form-numeric') ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
</div>
</form>
diff --git a/app/Template/custom_filter/add.php b/app/Template/custom_filter/add.php
index 61df148c..361083ee 100644
--- a/app/Template/custom_filter/add.php
+++ b/app/Template/custom_filter/add.php
@@ -12,10 +12,10 @@
<?= $this->form->label(t('Filter'), 'filter') ?>
<?= $this->form->text('filter', $values, $errors, array('required', 'maxlength="100"')) ?>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
+ <?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $project['id'])): ?>
<?= $this->form->checkbox('is_shared', t('Share with all project members'), 1) ?>
<?php endif ?>
-
+
<?= $this->form->checkbox('append', t('Append filter (instead of replacement)'), 1) ?>
<div class="form-actions">
diff --git a/app/Template/custom_filter/edit.php b/app/Template/custom_filter/edit.php
index 9d296b84..01fb4ec5 100644
--- a/app/Template/custom_filter/edit.php
+++ b/app/Template/custom_filter/edit.php
@@ -2,7 +2,7 @@
<h2><?= t('Edit custom filter') ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('customfilter', 'update', array('project_id' => $filter['project_id'], 'filter_id' => $filter['id'])) ?>" autocomplete="off">
+<form class="form-popover" method="post" action="<?= $this->url->href('customfilter', 'update', array('project_id' => $filter['project_id'], 'filter_id' => $filter['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -16,17 +16,17 @@
<?= $this->form->label(t('Filter'), 'filter') ?>
<?= $this->form->text('filter', $values, $errors, array('required', 'maxlength="100"')) ?>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
+ <?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $project['id'])): ?>
<?= $this->form->checkbox('is_shared', t('Share with all project members'), 1, $values['is_shared'] == 1) ?>
<?php else: ?>
<?= $this->form->hidden('is_shared', $values) ?>
<?php endif ?>
-
+
<?= $this->form->checkbox('append', t('Append filter (instead of replacement)'), 1, $values['append'] == 1) ?>
-
+
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'customfilter', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'customfilter', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/custom_filter/index.php b/app/Template/custom_filter/index.php
index c857e206..28b6a878 100644
--- a/app/Template/custom_filter/index.php
+++ b/app/Template/custom_filter/index.php
@@ -5,12 +5,12 @@
<div>
<table>
<tr>
- <th><?= t('Name') ?></th>
- <th><?= t('Filter') ?></th>
- <th><?= t('Shared') ?></th>
- <th><?= t('Append/Replace') ?></th>
- <th><?= t('Owner') ?></th>
- <th><?= t('Actions') ?></th>
+ <th class="column-15"><?= t('Name') ?></th>
+ <th class="column-30"><?= t('Filter') ?></th>
+ <th class="column-10"><?= t('Shared') ?></th>
+ <th class="column-15"><?= t('Append/Replace') ?></th>
+ <th class="column-25"><?= t('Owner') ?></th>
+ <th class="column-5"><?= t('Actions') ?></th>
</tr>
<?php foreach ($custom_filters as $filter): ?>
<tr>
@@ -32,11 +32,14 @@
</td>
<td><?= $this->e($filter['owner_name'] ?: $filter['owner_username']) ?></td>
<td>
- <?php if ($filter['user_id'] == $this->user->getId() || $this->user->isProjectManagementAllowed($project['id'])): ?>
+ <?php if ($filter['user_id'] == $this->user->getId() || $this->user->hasProjectAccess('customfilter', 'edit', $project['id'])): ?>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
<ul>
- <li><?= $this->url->link(t('Remove'), 'customfilter', 'remove', array('project_id' => $filter['project_id'], 'filter_id' => $filter['id']), true) ?></li>
- <li><?= $this->url->link(t('Edit'), 'customfilter', 'edit', array('project_id' => $filter['project_id'], 'filter_id' => $filter['id'])) ?></li>
+ <li><?= $this->url->link(t('Remove'), 'customfilter', 'confirm', array('project_id' => $filter['project_id'], 'filter_id' => $filter['id']), false, 'popover') ?></li>
+ <li><?= $this->url->link(t('Edit'), 'customfilter', 'edit', array('project_id' => $filter['project_id'], 'filter_id' => $filter['id']), false, 'popover') ?></li>
</ul>
+ </div>
<?php endif ?>
</td>
</tr>
diff --git a/app/Template/custom_filter/remove.php b/app/Template/custom_filter/remove.php
new file mode 100644
index 00000000..d4c67a2b
--- /dev/null
+++ b/app/Template/custom_filter/remove.php
@@ -0,0 +1,17 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove a custom filter') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this custom filter: "%s"?', $filter['name']) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'customfilter', 'remove', array('project_id' => $project['id'], 'filter_id' => $filter['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'customfilter', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+ </div>
+</section>
diff --git a/app/Template/event/events.php b/app/Template/event/events.php
index aec0b29e..bbb01be4 100644
--- a/app/Template/event/events.php
+++ b/app/Template/event/events.php
@@ -14,7 +14,7 @@
<?php elseif ($this->text->contains($event['event_name'], 'comment')): ?>
<i class="fa fa-comments-o"></i>
<?php endif ?>
- &nbsp;<?= dt('%B %e, %Y at %k:%M %p', $event['date_creation']) ?>
+ &nbsp;<?= $this->dt->datetime($event['date_creation']) ?>
</p>
<div class="activity-content"><?= $event['event_content'] ?></div>
</div>
diff --git a/app/Template/event/task_file_create.php b/app/Template/event/task_file_create.php
new file mode 100644
index 00000000..1a36bc8f
--- /dev/null
+++ b/app/Template/event/task_file_create.php
@@ -0,0 +1,11 @@
+<?= $this->user->avatar($email, $author) ?>
+
+<p class="activity-title">
+ <?= e('%s attached a new file to the task %s',
+ $this->e($author),
+ $this->url->link(t('#%d', $task['id']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))
+ ) ?>
+</p>
+<p class="activity-description">
+ <em><?= $this->e($file['name']) ?></em>
+</p> \ No newline at end of file
diff --git a/app/Template/export/sidebar.php b/app/Template/export/sidebar.php
index 44448520..6a1de7e9 100644
--- a/app/Template/export/sidebar.php
+++ b/app/Template/export/sidebar.php
@@ -15,6 +15,4 @@
</li>
<?= $this->hook->render('template:export:sidebar') ?>
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
</div> \ No newline at end of file
diff --git a/app/Template/export/subtasks.php b/app/Template/export/subtasks.php
index 4aad2641..f2a00f84 100644
--- a/app/Template/export/subtasks.php
+++ b/app/Template/export/subtasks.php
@@ -1,7 +1,5 @@
<div class="page-header">
- <h2>
- <?= t('Subtasks exportation for "%s"', $project['name']) ?>
- </h2>
+ <h2><?= t('Subtasks exportation for "%s"', $project['name']) ?></h2>
</div>
<p class="alert alert-info"><?= t('This report contains all subtasks information for the given date range.') ?></p>
@@ -13,7 +11,7 @@
<?= $this->form->hidden('project_id', $values) ?>
<?= $this->form->label(t('Start Date'), 'from') ?>
- <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?><br/>
+ <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
<?= $this->form->label(t('End Date'), 'to') ?>
<?= $this->form->text('to', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
@@ -21,6 +19,6 @@
<div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
<div class="form-actions">
- <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue">
</div>
</form> \ No newline at end of file
diff --git a/app/Template/export/summary.php b/app/Template/export/summary.php
index ffbd6ac2..0c2a96fb 100644
--- a/app/Template/export/summary.php
+++ b/app/Template/export/summary.php
@@ -1,7 +1,5 @@
<div class="page-header">
- <h2>
- <?= t('Daily project summary export for "%s"', $project['name']) ?>
- </h2>
+ <h2><?= t('Daily project summary export for "%s"', $project['name']) ?></h2>
</div>
<p class="alert alert-info"><?= t('This export contains the number of tasks per column grouped per day.') ?></p>
@@ -13,7 +11,7 @@
<?= $this->form->hidden('project_id', $values) ?>
<?= $this->form->label(t('Start Date'), 'from') ?>
- <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?><br/>
+ <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
<?= $this->form->label(t('End Date'), 'to') ?>
<?= $this->form->text('to', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
@@ -21,6 +19,6 @@
<div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
<div class="form-actions">
- <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue">
</div>
</form> \ No newline at end of file
diff --git a/app/Template/export/tasks.php b/app/Template/export/tasks.php
index c74c8f98..c27149d2 100644
--- a/app/Template/export/tasks.php
+++ b/app/Template/export/tasks.php
@@ -1,7 +1,5 @@
<div class="page-header">
- <h2>
- <?= t('Tasks exportation for "%s"', $project['name']) ?>
- </h2>
+ <h2><?= t('Tasks exportation for "%s"', $project['name']) ?></h2>
</div>
<p class="alert alert-info"><?= t('This report contains all tasks information for the given date range.') ?></p>
@@ -13,7 +11,7 @@
<?= $this->form->hidden('project_id', $values) ?>
<?= $this->form->label(t('Start Date'), 'from') ?>
- <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?><br/>
+ <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
<?= $this->form->label(t('End Date'), 'to') ?>
<?= $this->form->text('to', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
@@ -21,6 +19,6 @@
<div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
<div class="form-actions">
- <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue">
</div>
</form> \ No newline at end of file
diff --git a/app/Template/export/transitions.php b/app/Template/export/transitions.php
index bf6ef249..d935bde1 100644
--- a/app/Template/export/transitions.php
+++ b/app/Template/export/transitions.php
@@ -1,7 +1,5 @@
<div class="page-header">
- <h2>
- <?= t('Task transitions export') ?>
- </h2>
+ <h2><?= t('Task transitions export') ?></h2>
</div>
<p class="alert alert-info"><?= t('This report contains all column moves for each task with the date, the user and the time spent for each transition.') ?></p>
@@ -13,7 +11,7 @@
<?= $this->form->hidden('project_id', $values) ?>
<?= $this->form->label(t('Start Date'), 'from') ?>
- <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?><br/>
+ <?= $this->form->text('from', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
<?= $this->form->label(t('End Date'), 'to') ?>
<?= $this->form->text('to', $values, $errors, array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
@@ -21,6 +19,6 @@
<div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
<div class="form-actions">
- <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue">
</div>
</form> \ No newline at end of file
diff --git a/app/Template/file/new.php b/app/Template/file/new.php
deleted file mode 100644
index a1a59eae..00000000
--- a/app/Template/file/new.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<div class="page-header">
- <h2><?= t('Attach a document') ?></h2>
-</div>
-
-<form action="<?= $this->url->href('file', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" enctype="multipart/form-data">
- <?= $this->form->csrf() ?>
- <input type="file" name="files[]" multiple />
- <div class="form-help"><?= t('Maximum size: ') ?><?= is_integer($max_size) ? $this->text->bytes($max_size) : $max_size ?></div>
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- <?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </div>
-</form> \ No newline at end of file
diff --git a/app/Template/file/open.php b/app/Template/file/open.php
deleted file mode 100644
index 3df012b6..00000000
--- a/app/Template/file/open.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<div class="page-header">
- <h2><?= $this->e($file['name']) ?></h2>
- <div class="task-file-viewer">
- <img src="<?= $this->url->href('file', 'image', array('file_id' => $file['id'], 'project_id' => $task['project_id'], 'task_id' => $file['task_id'])) ?>" alt="<?= $this->e($file['name']) ?>"/>
- </div>
-</div> \ No newline at end of file
diff --git a/app/Template/file/show.php b/app/Template/file/show.php
deleted file mode 100644
index a390c9fb..00000000
--- a/app/Template/file/show.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php if (! empty($files) || ! empty($images)): ?>
-<div id="attachments" class="task-show-section">
-
- <div class="page-header">
- <h2><?= t('Attachments') ?></h2>
- </div>
- <?php if (! empty($images)): ?>
- <h3><?= t('Images') ?></h3>
- <ul class="task-show-images">
- <?php foreach ($images as $file): ?>
- <li>
- <?php if (function_exists('imagecreatetruecolor')): ?>
- <div class="img_container">
- <img src="<?= $this->url->href('file', 'thumbnail', array('file_id' => $file['id'], 'project_id' => $task['project_id'], 'task_id' => $file['task_id'])) ?>" alt="<?= $this->e($file['name']) ?>"/>
- </div>
- <?php endif ?>
- <p>
- <?= $this->e($file['name']) ?>
- <span class="tooltip" title='<?= t('uploaded by: %s', $file['user_name'] ?: $file['username']).'<br>'.t('uploaded on: %s', dt('%B %e, %Y at %k:%M %p', $file['date'])).'<br>'.t('size: %s', $this->text->bytes($file['size'])) ?>'>
- <i class="fa fa-info-circle"></i>
- </span>
- </p>
- <span class="task-show-file-actions task-show-image-actions">
- <i class="fa fa-eye"></i> <?= $this->url->link(t('open file'), 'file', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), false, 'popover') ?>
- <i class="fa fa-trash"></i> <?= $this->url->link(t('remove'), 'file', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
- <i class="fa fa-download"></i> <?= $this->url->link(t('download'), 'file', 'download', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
- </span>
- </li>
- <?php endforeach ?>
- </ul>
- <?php endif ?>
-
- <?php if (! empty($files)): ?>
- <h3><?= t('Files') ?></h3>
- <table class="task-show-file-table">
- <?php foreach ($files as $file): ?>
- <tr>
- <td><i class="fa <?= $this->file->icon($file['name']) ?> fa-fw"></i></td>
- <td>
- <?= $this->e($file['name']) ?>
- <span class="tooltip" title='<?= t('uploaded by: %s', $file['user_name'] ?: $file['username']).'<br>'.t('uploaded on: %s', dt('%B %e, %Y at %k:%M %p', $file['date'])).'<br>'.t('size: %s', $this->text->bytes($file['size'])) ?>'>
- <i class="fa fa-info-circle"></i>
- </span>
- </td>
- <td>
- <span class="task-show-file-actions">
- <i class="fa fa-trash"></i> <?= $this->url->link(t('remove'), 'file', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
- <i class="fa fa-download"></i> <?= $this->url->link(t('download'), 'file', 'download', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
- </span>
- </td>
- </tr>
- <?php endforeach ?>
- </table>
- <?php endif ?>
-</div>
-<?php endif ?> \ No newline at end of file
diff --git a/app/Template/file_viewer/show.php b/app/Template/file_viewer/show.php
new file mode 100644
index 00000000..c71ef91c
--- /dev/null
+++ b/app/Template/file_viewer/show.php
@@ -0,0 +1,14 @@
+<div class="page-header">
+ <h2><?= $this->e($file['name']) ?></h2>
+</div>
+<div class="file-viewer">
+ <?php if ($file['is_image']): ?>
+ <img src="<?= $this->url->href('FileViewer', 'image', $params) ?>" alt="<?= $this->e($file['name']) ?>">
+ <?php elseif ($type === 'markdown'): ?>
+ <article class="markdown">
+ <?= $this->text->markdown($content) ?>
+ </article>
+ <?php elseif ($type === 'text'): ?>
+ <pre><?= $content ?></pre>
+ <?php endif ?>
+</div> \ No newline at end of file
diff --git a/app/Template/gantt/project.php b/app/Template/gantt/project.php
index 1face3b8..fe193c2b 100644
--- a/app/Template/gantt/project.php
+++ b/app/Template/gantt/project.php
@@ -1,5 +1,5 @@
<section id="main">
- <?= $this->render('project/filters', array(
+ <?= $this->render('project_header/header', array(
'project' => $project,
'filters' => $filters,
'users_list' => $users_list,
diff --git a/app/Template/gantt/projects.php b/app/Template/gantt/projects.php
index 50e244a5..84b260bb 100644
--- a/app/Template/gantt/projects.php
+++ b/app/Template/gantt/projects.php
@@ -1,16 +1,10 @@
<section id="main">
<div class="page-header">
<ul>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
- <li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New project'), 'project', 'create') ?></li>
- <?php endif ?>
- <li>
- <i class="fa fa-lock fa-fw"></i><?= $this->url->link(t('New private project'), 'project', 'create', array('private' => 1)) ?>
- </li>
<li>
<i class="fa fa-folder fa-fw"></i><?= $this->url->link(t('Projects list'), 'project', 'index') ?>
</li>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('projectuser', 'managers')): ?>
<li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('Users overview'), 'projectuser', 'managers') ?></li>
<?php endif ?>
</ul>
diff --git a/app/Template/gantt/task_creation.php b/app/Template/gantt/task_creation.php
index 7997e231..a08f41b4 100644
--- a/app/Template/gantt/task_creation.php
+++ b/app/Template/gantt/task_creation.php
@@ -33,26 +33,13 @@
</div>
<div class="form-column">
- <?= $this->form->label(t('Assignee'), 'owner_id') ?>
- <?= $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, 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, array('tabindex="5"')) ?><br/>
- <?php endif ?>
-
- <?= $this->form->label(t('Complexity'), 'score') ?>
- <?= $this->form->number('score', $values, $errors, array('tabindex="6"')) ?><br/>
-
- <?= $this->form->label(t('Start Date'), 'date_started') ?>
- <?= $this->form->text('date_started', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"', 'tabindex="7"'), 'form-date') ?>
-
- <?= $this->form->label(t('Due Date'), 'date_due') ?>
- <?= $this->form->text('date_due', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"', 'tabindex="8"'), 'form-date') ?><br/>
- <div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
+ <?= $this->task->selectAssignee($users_list, $values, $errors) ?>
+ <?= $this->task->selectCategory($categories_list, $values, $errors) ?>
+ <?= $this->task->selectSwimlane($swimlanes_list, $values, $errors) ?>
+ <?= $this->task->selectPriority($project, $values) ?>
+ <?= $this->task->selectScore($values, $errors) ?>
+ <?= $this->task->selectStartDate($values, $errors) ?>
+ <?= $this->task->selectDueDate($values, $errors) ?>
</div>
<div class="form-actions">
diff --git a/app/Template/group/associate.php b/app/Template/group/associate.php
new file mode 100644
index 00000000..468281e2
--- /dev/null
+++ b/app/Template/group/associate.php
@@ -0,0 +1,25 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
+ <li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('View group members'), 'group', 'users', array('group_id' => $group['id'])) ?></li>
+ </ul>
+ </div>
+ <?php if (empty($users)): ?>
+ <p class="alert"><?= t('There is no user available.') ?></p>
+ <?php else: ?>
+ <form method="post" action="<?= $this->url->href('group', 'addUser', array('group_id' => $group['id'])) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('group_id', $values) ?>
+
+ <?= $this->form->label(t('User'), 'user_id') ?>
+ <?= $this->form->select('user_id', $users, $values, $errors, array('required'), 'chosen-select') ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'group', 'index') ?>
+ </div>
+ </form>
+ <?php endif ?>
+</section>
diff --git a/app/Template/group/create.php b/app/Template/group/create.php
new file mode 100644
index 00000000..4a935c08
--- /dev/null
+++ b/app/Template/group/create.php
@@ -0,0 +1,19 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
+ </ul>
+ </div>
+ <form method="post" action="<?= $this->url->href('group', 'save') ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="100"')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'group', 'index') ?>
+ </div>
+ </form>
+</section>
diff --git a/app/Template/group/dissociate.php b/app/Template/group/dissociate.php
new file mode 100644
index 00000000..e1c60764
--- /dev/null
+++ b/app/Template/group/dissociate.php
@@ -0,0 +1,17 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
+ <li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('View group members'), 'group', 'users', array('group_id' => $group['id'])) ?></li>
+ </ul>
+ </div>
+ <div class="confirm">
+ <p class="alert alert-info"><?= t('Do you really want to remove the user "%s" from the group "%s"?', $user['name'] ?: $user['username'], $group['name']) ?></p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'group', 'removeUser', array('group_id' => $group['id'], 'user_id' => $user['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'group', 'users', array('group_id' => $group['id'])) ?>
+ </div>
+ </div>
+</section>
diff --git a/app/Template/group/edit.php b/app/Template/group/edit.php
new file mode 100644
index 00000000..d9646ee8
--- /dev/null
+++ b/app/Template/group/edit.php
@@ -0,0 +1,22 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
+ </ul>
+ </div>
+ <form method="post" action="<?= $this->url->href('group', 'update') ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('external_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="100"')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'group', 'index') ?>
+ </div>
+ </form>
+</section>
diff --git a/app/Template/group/index.php b/app/Template/group/index.php
new file mode 100644
index 00000000..d111b5d9
--- /dev/null
+++ b/app/Template/group/index.php
@@ -0,0 +1,46 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('All users'), 'user', 'index') ?></li>
+ <li><i class="fa fa-user-plus fa-fw"></i><?= $this->url->link(t('New group'), 'group', 'create') ?></li>
+ </ul>
+ </div>
+ <?php if ($paginator->isEmpty()): ?>
+ <p class="alert"><?= t('There is no group.') ?></p>
+ <?php else: ?>
+ <table class="table-small table-fixed">
+ <tr>
+ <th class="column-5"><?= $paginator->order(t('Id'), 'id') ?></th>
+ <th class="column-20"><?= $paginator->order(t('External Id'), 'external_id') ?></th>
+ <th><?= $paginator->order(t('Name'), 'name') ?></th>
+ <th class="column-5"><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($paginator->getCollection() as $group): ?>
+ <tr>
+ <td>
+ #<?= $group['id'] ?>
+ </td>
+ <td>
+ <?= $this->e($group['external_id']) ?>
+ </td>
+ <td>
+ <?= $this->e($group['name']) ?>
+ </td>
+ <td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li><?= $this->url->link(t('Add group member'), 'group', 'associate', array('group_id' => $group['id'])) ?></li>
+ <li><?= $this->url->link(t('Members'), 'group', 'users', array('group_id' => $group['id'])) ?></li>
+ <li><?= $this->url->link(t('Edit'), 'group', 'edit', array('group_id' => $group['id'])) ?></li>
+ <li><?= $this->url->link(t('Remove'), 'group', 'confirm', array('group_id' => $group['id'])) ?></li>
+ </ul>
+ </div>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <?= $paginator ?>
+ <?php endif ?>
+</section>
diff --git a/app/Template/group/remove.php b/app/Template/group/remove.php
new file mode 100644
index 00000000..1cb007b1
--- /dev/null
+++ b/app/Template/group/remove.php
@@ -0,0 +1,17 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
+ <li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('View group members'), 'group', 'users', array('group_id' => $group['id'])) ?></li>
+ </ul>
+ </div>
+ <div class="confirm">
+ <p class="alert alert-info"><?= t('Do you really want to remove this group: "%s"?', $group['name']) ?></p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'group', 'remove', array('group_id' => $group['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'group', 'index') ?>
+ </div>
+ </div>
+</section>
diff --git a/app/Template/group/users.php b/app/Template/group/users.php
new file mode 100644
index 00000000..f79cb9ad
--- /dev/null
+++ b/app/Template/group/users.php
@@ -0,0 +1,42 @@
+<section id="main">
+ <div class="page-header">
+ <ul>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
+ <li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('Add group member'), 'group', 'associate', array('group_id' => $group['id'])) ?></li>
+ </ul>
+ </div>
+ <?php if ($paginator->isEmpty()): ?>
+ <p class="alert"><?= t('There is no user in this group.') ?></p>
+ <?php else: ?>
+ <table>
+ <tr>
+ <th><?= $paginator->order(t('Id'), 'id') ?></th>
+ <th><?= $paginator->order(t('Username'), 'username') ?></th>
+ <th><?= $paginator->order(t('Name'), 'name') ?></th>
+ <th><?= $paginator->order(t('Email'), 'email') ?></th>
+ <th><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($paginator->getCollection() as $user): ?>
+ <tr>
+ <td>
+ <?= $this->url->link('#'.$user['id'], 'user', 'show', array('user_id' => $user['id'])) ?>
+ </td>
+ <td>
+ <?= $this->url->link($this->e($user['username']), 'user', 'show', array('user_id' => $user['id'])) ?>
+ </td>
+ <td>
+ <?= $this->e($user['name']) ?>
+ </td>
+ <td>
+ <a href="mailto:<?= $this->e($user['email']) ?>"><?= $this->e($user['email']) ?></a>
+ </td>
+ <td>
+ <?= $this->url->link(t('Remove this user'), 'group', 'dissociate', array('group_id' => $group['id'], 'user_id' => $user['id'])) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <?= $paginator ?>
+ <?php endif ?>
+</section>
diff --git a/app/Template/header.php b/app/Template/header.php
index e8fd90c7..e646012a 100644
--- a/app/Template/header.php
+++ b/app/Template/header.php
@@ -1,6 +1,12 @@
<header>
<nav>
- <h1><?= $this->url->link('K<span>B</span>', 'app', 'index', array(), false, 'logo', t('Dashboard')).' '.$this->e($title) ?>
+ <h1>
+ <span class="logo">
+ <?= $this->url->link('K<span>B</span>', 'app', 'index', array(), false, '', t('Dashboard')) ?>
+ </span>
+ <span class="title">
+ <?= $this->e($title) ?>
+ </span>
<?php if (! empty($description)): ?>
<span class="tooltip" title='<?= $this->e($this->text->markdown($description)) ?>'>
<i class="fa fa-info-circle"></i>
@@ -13,6 +19,7 @@
<select id="board-selector"
class="chosen-select select-auto-redirect"
tabindex="-1"
+ data-search-threshold="0"
data-notfound="<?= t('No results match:') ?>"
data-placeholder="<?= t('Display another project') ?>"
data-redirect-regex="PROJECT_ID"
@@ -24,14 +31,75 @@
</select>
</li>
<?php endif ?>
- <li>
+ <li class="user-links">
<?php if ($this->user->hasNotifications()): ?>
- <?= $this->url->link('<i class="fa fa-bell web-notification-icon"></i>', 'app', 'notifications', array('user_id' => $this->user->getId()), false, '', t('Unread notifications')) ?>
+ <span class="notification">
+ <?= $this->url->link('<i class="fa fa-bell web-notification-icon"></i>', 'app', 'notifications', array('user_id' => $this->user->getId()), false, '', t('Unread notifications')) ?>
+ </span>
+ <?php endif ?>
+
+ <?php $has_project_creation_access = $this->user->hasAccess('ProjectCreation', 'create'); ?>
+ <?php $is_private_project_enabled = $this->app->config('disable_private_project', 0) == 0; ?>
+
+ <?php if ($has_project_creation_access || (!$has_project_creation_access && $is_private_project_enabled)): ?>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-plus fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <?php if ($has_project_creation_access): ?>
+ <li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New project'), 'ProjectCreation', 'create', array(), false, 'popover') ?></li>
+ <?php endif ?>
+ <?php if ($is_private_project_enabled): ?>
+ <li>
+ <i class="fa fa-lock fa-fw"></i><?= $this->url->link(t('New private project'), 'ProjectCreation', 'createPrivate', array(), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
<?php endif ?>
- <?= $this->url->link(t('Logout'), 'auth', 'logout') ?>
- <span class="username hide-tablet">(<?= $this->user->getProfileLink() ?>)</span>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-user fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li class="no-hover"><strong><?= $this->e($this->user->getFullname()) ?></strong></li>
+ <li>
+ <i class="fa fa-tachometer fa-fw"></i>
+ <?= $this->url->link(t('My dashboard'), 'app', 'index', array('user_id' => $this->user->getId())) ?>
+ </li>
+ <li>
+ <i class="fa fa-home fa-fw"></i>
+ <?= $this->url->link(t('My profile'), 'user', 'show', array('user_id' => $this->user->getId())) ?>
+ </li>
+ <li>
+ <i class="fa fa-folder fa-fw"></i>
+ <?= $this->url->link(t('Projects management'), 'project', 'index') ?>
+ </li>
+ <?php if ($this->user->hasAccess('user', 'index')): ?>
+ <li>
+ <i class="fa fa-user fa-fw"></i>
+ <?= $this->url->link(t('Users management'), 'user', 'index') ?>
+ </li>
+ <li>
+ <i class="fa fa-group fa-fw"></i>
+ <?= $this->url->link(t('Groups management'), 'group', 'index') ?>
+ </li>
+ <li>
+ <i class="fa fa-cog fa-fw"></i>
+ <?= $this->url->link(t('Settings'), 'config', 'index') ?>
+ </li>
+ <?php endif ?>
+ <li>
+ <i class="fa fa-life-ring fa-fw"></i>
+ <?= $this->url->link(t('Documentation'), 'doc', 'show') ?>
+ </li>
+ <?php if (! DISABLE_LOGOUT): ?>
+ <li>
+ <i class="fa fa-sign-out fa-fw"></i>
+ <?= $this->url->link(t('Logout'), 'auth', 'logout') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
</li>
</ul>
</nav>
-</header> \ No newline at end of file
+</header>
diff --git a/app/Template/layout.php b/app/Template/layout.php
index 20582952..0c81aac2 100644
--- a/app/Template/layout.php
+++ b/app/Template/layout.php
@@ -36,7 +36,7 @@
</head>
<body data-status-url="<?= $this->url->href('app', 'status') ?>"
data-login-url="<?= $this->url->href('auth', 'login') ?>"
- data-markdown-preview-url="<?= $this->url->href('app', 'preview') ?>"
+ data-markdown-preview-url="<?= $this->url->href('TaskHelper', 'preview') ?>"
data-timezone="<?= $this->app->getTimezone() ?>"
data-js-lang="<?= $this->app->jsLang() ?>">
diff --git a/app/Template/listing/show.php b/app/Template/listing/show.php
index aa17b228..9a5992e3 100644
--- a/app/Template/listing/show.php
+++ b/app/Template/listing/show.php
@@ -1,10 +1,13 @@
<section id="main">
- <?= $this->render('project/filters', array(
+ <?= $this->render('project_header/header', array(
'project' => $project,
'filters' => $filters,
+ 'custom_filters_list' => $custom_filters_list,
+ 'users_list' => $users_list,
+ 'categories_list' => $categories_list,
)) ?>
- <?php if (! empty($values['search']) && $paginator->isEmpty()): ?>
+ <?php if ($paginator->isEmpty()): ?>
<p class="alert"><?= t('No tasks found.') ?></p>
<?php elseif (! $paginator->isEmpty()): ?>
<table class="table-fixed table-small">
@@ -21,7 +24,7 @@
<?php foreach ($paginator->getCollection() as $task): ?>
<tr>
<td class="task-table color-<?= $task['color_id'] ?>">
- <?= $this->url->link('#'.$this->e($task['id']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, '', t('View this task')) ?>
+ <?= $this->render('task/dropdown', array('task' => $task)) ?>
</td>
<td>
<?= $this->e($task['swimlane_name'] ?: $task['default_swimlane']) ?>
@@ -43,7 +46,7 @@
<?php endif ?>
</td>
<td>
- <?= dt('%B %e, %Y', $task['date_due']) ?>
+ <?= $this->dt->date($task['date_due']) ?>
</td>
<td>
<?php if ($task['is_active'] == \Kanboard\Model\Task::STATUS_OPEN): ?>
diff --git a/app/Template/notification/comment_user_mention.php b/app/Template/notification/comment_user_mention.php
new file mode 100644
index 00000000..59f5127e
--- /dev/null
+++ b/app/Template/notification/comment_user_mention.php
@@ -0,0 +1,7 @@
+<h2><?= t('You were mentioned in a comment on the task #%d', $task['id']) ?></h2>
+
+<p><?= $this->e($task['title']) ?></p>
+
+<?= $this->text->markdown($comment['comment']) ?>
+
+<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> \ No newline at end of file
diff --git a/app/Template/notification/task_create.php b/app/Template/notification/task_create.php
index 1d834d44..fb7898fc 100644
--- a/app/Template/notification/task_create.php
+++ b/app/Template/notification/task_create.php
@@ -2,11 +2,11 @@
<ul>
<li>
- <?= dt('Created on %B %e, %Y at %k:%M %p', $task['date_creation']) ?>
+ <?= t('Created:').' '.$this->dt->datetime($task['date_creation']) ?>
</li>
<?php if ($task['date_due']): ?>
<li>
- <strong><?= dt('Must be done before %B %e, %Y', $task['date_due']) ?></strong>
+ <strong><?= t('Due date:').' '.$this->dt->date($task['date_due']) ?></strong>
</li>
<?php endif ?>
<?php if (! empty($task['creator_username'])): ?>
diff --git a/app/Template/notification/file_create.php b/app/Template/notification/task_file_create.php
index 63f7d1b8..63f7d1b8 100644
--- a/app/Template/notification/file_create.php
+++ b/app/Template/notification/task_file_create.php
diff --git a/app/Template/notification/task_overdue.php b/app/Template/notification/task_overdue.php
index a231937b..d7e6ff5a 100644
--- a/app/Template/notification/task_overdue.php
+++ b/app/Template/notification/task_overdue.php
@@ -9,7 +9,7 @@
<?php else: ?>
<?= $this->e($task['title']) ?>
<?php endif ?>
- (<?= dt('%B %e, %Y', $task['date_due']) ?>)
+ (<?= $this->dt->date($task['date_due']) ?>)
<?php if ($task['assignee_username']): ?>
(<strong><?= t('Assigned to %s', $task['assignee_name'] ?: $task['assignee_username']) ?></strong>)
<?php endif ?>
diff --git a/app/Template/notification/task_user_mention.php b/app/Template/notification/task_user_mention.php
new file mode 100644
index 00000000..40ddddca
--- /dev/null
+++ b/app/Template/notification/task_user_mention.php
@@ -0,0 +1,7 @@
+<h2><?= t('You were mentioned in the task #%d', $task['id']) ?></h2>
+<p><?= $this->e($task['title']) ?></p>
+
+<h2><?= t('Description') ?></h2>
+<?= $this->text->markdown($task['description']) ?>
+
+<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> \ No newline at end of file
diff --git a/app/Template/password_reset/change.php b/app/Template/password_reset/change.php
new file mode 100644
index 00000000..6d06f442
--- /dev/null
+++ b/app/Template/password_reset/change.php
@@ -0,0 +1,16 @@
+<div class="form-login">
+ <h2><?= t('Password Reset') ?></h2>
+ <form method="post" action="<?= $this->url->href('PasswordReset', 'update', array('token' => $token)) ?>">
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->label(t('New password'), 'password') ?>
+ <?= $this->form->password('password', $values, $errors) ?>
+
+ <?= $this->form->label(t('Confirmation'), 'confirmation') ?>
+ <?= $this->form->password('confirmation', $values, $errors) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Change Password') ?>" class="btn btn-blue">
+ </div>
+ </form>
+</div> \ No newline at end of file
diff --git a/app/Template/password_reset/create.php b/app/Template/password_reset/create.php
new file mode 100644
index 00000000..ef958011
--- /dev/null
+++ b/app/Template/password_reset/create.php
@@ -0,0 +1,17 @@
+<div class="form-login">
+ <h2><?= t('Password Reset') ?></h2>
+ <form method="post" action="<?= $this->url->href('PasswordReset', 'save') ?>">
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->label(t('Username'), 'username') ?>
+ <?= $this->form->text('username', $values, $errors, array('autofocus', 'required')) ?>
+
+ <?= $this->form->label(t('Enter the text below'), 'captcha') ?>
+ <img src="<?= $this->url->href('Captcha', 'image') ?>"/>
+ <?= $this->form->text('captcha', array(), $errors, array('required')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Change Password') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+</div> \ No newline at end of file
diff --git a/app/Template/password_reset/email.php b/app/Template/password_reset/email.php
new file mode 100644
index 00000000..62788b49
--- /dev/null
+++ b/app/Template/password_reset/email.php
@@ -0,0 +1,6 @@
+<p><?= t('To reset your password click on this link:') ?></p>
+
+<p><?= $this->url->to('PasswordReset', 'change', array('token' => $token), '', true) ?></p>
+
+<hr>
+Kanboard \ No newline at end of file
diff --git a/app/Template/project/dropdown.php b/app/Template/project/dropdown.php
index 1eb87b0e..980f9a44 100644
--- a/app/Template/project/dropdown.php
+++ b/app/Template/project/dropdown.php
@@ -2,10 +2,13 @@
<i class="fa fa-dashboard fa-fw"></i>&nbsp;
<?= $this->url->link(t('Activity'), 'activity', 'project', array('project_id' => $project['id'])) ?>
</li>
+
+<?php if ($this->user->hasProjectAccess('customfilter', 'index', $project['id'])): ?>
<li>
<i class="fa fa-filter fa-fw"></i>&nbsp;
<?= $this->url->link(t('Custom filters'), 'customfilter', 'index', array('project_id' => $project['id'])) ?>
</li>
+<?php endif ?>
<?php if ($project['is_public']): ?>
<li>
@@ -15,15 +18,21 @@
<?= $this->hook->render('template:project:dropdown', array('project' => $project)) ?>
-<?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
+<?php if ($this->user->hasProjectAccess('analytic', 'tasks', $project['id'])): ?>
<li>
<i class="fa fa-line-chart fa-fw"></i>&nbsp;
<?= $this->url->link(t('Analytics'), 'analytic', 'tasks', array('project_id' => $project['id'])) ?>
</li>
+<?php endif ?>
+
+<?php if ($this->user->hasProjectAccess('export', 'tasks', $project['id'])): ?>
<li>
<i class="fa fa-download fa-fw"></i>&nbsp;
<?= $this->url->link(t('Exports'), 'export', 'tasks', array('project_id' => $project['id'])) ?>
</li>
+<?php endif ?>
+
+<?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $project['id'])): ?>
<li>
<i class="fa fa-cog fa-fw"></i>&nbsp;
<?= $this->url->link(t('Settings'), 'project', 'show', array('project_id' => $project['id'])) ?>
diff --git a/app/Template/project/duplicate.php b/app/Template/project/duplicate.php
index 8967c306..ca7d3302 100644
--- a/app/Template/project/duplicate.php
+++ b/app/Template/project/duplicate.php
@@ -10,13 +10,17 @@
<?= $this->form->csrf() ?>
+ <?php if ($project['is_private'] == 0): ?>
+ <?= $this->form->checkbox('projectPermission', t('Permissions'), 1, true) ?>
+ <?php endif ?>
+
<?= $this->form->checkbox('category', t('Categories'), 1, true) ?>
<?= $this->form->checkbox('action', t('Actions'), 1, true) ?>
<?= $this->form->checkbox('swimlane', t('Swimlanes'), 1, false) ?>
<?= $this->form->checkbox('task', t('Tasks'), 1, false) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Duplicate') ?>" class="btn btn-red"/>
+ <input type="submit" value="<?= t('Duplicate') ?>" class="btn btn-red">
<?= t('or') ?> <?= $this->url->link(t('cancel'), 'project', 'show', array('project_id' => $project['id'])) ?>
</div>
</form>
diff --git a/app/Template/project/edit.php b/app/Template/project/edit.php
deleted file mode 100644
index 8dcbb88f..00000000
--- a/app/Template/project/edit.php
+++ /dev/null
@@ -1,50 +0,0 @@
-<div class="page-header">
- <h2><?= t('Edit project') ?></h2>
-</div>
-<form method="post" action="<?= $this->url->href('project', 'update', array('project_id' => $project['id'])) ?>" autocomplete="off">
-
- <?= $this->form->csrf() ?>
- <?= $this->form->hidden('id', $values) ?>
-
- <?= $this->form->label(t('Name'), 'name') ?>
- <?= $this->form->text('name', $values, $errors, array('required', 'maxlength="50"')) ?>
-
- <?= $this->form->label(t('Identifier'), 'identifier') ?>
- <?= $this->form->text('identifier', $values, $errors, array('maxlength="50"')) ?>
- <p class="form-help"><?= t('The project identifier is an optional alphanumeric code used to identify your project.') ?></p>
-
- <?= $this->form->label(t('Start date'), 'start_date') ?>
- <?= $this->form->text('start_date', $values, $errors, array('maxlength="10"'), 'form-date') ?>
-
- <?= $this->form->label(t('End date'), 'end_date') ?>
- <?= $this->form->text('end_date', $values, $errors, array('maxlength="10"'), 'form-date') ?>
-
- <?php if ($this->user->isAdmin() || $this->user->isProjectAdministrationAllowed($project['id'])): ?>
- <?= $this->form->checkbox('is_private', t('Private project'), 1, $project['is_private'] == 1) ?>
- <?php endif ?>
-
- <?= $this->form->label(t('Description'), 'description') ?>
-
- <div class="form-tabs">
-
- <div class="write-area">
- <?= $this->form->textarea('description', $values, $errors) ?>
- </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>
- </li>
- <li class="form-tab">
- <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
- </li>
- </ul>
- </div>
- <div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- </div>
-</form>
diff --git a/app/Template/project/filters.php b/app/Template/project/filters.php
deleted file mode 100644
index c17cfb3c..00000000
--- a/app/Template/project/filters.php
+++ /dev/null
@@ -1,101 +0,0 @@
-<div class="page-header">
- <div class="dropdown">
- <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Actions') ?></a>
- <ul>
- <?php if (isset($is_board)): ?>
- <li>
- <span class="filter-display-mode" <?= $this->board->isCollapsed($project['id']) ? '' : 'style="display: none;"' ?>>
- <i class="fa fa-expand fa-fw"></i>
- <?= $this->url->link(t('Expand tasks'), 'board', 'expand', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
- </span>
- <span class="filter-display-mode" <?= $this->board->isCollapsed($project['id']) ? 'style="display: none;"' : '' ?>>
- <i class="fa fa-compress fa-fw"></i>
- <?= $this->url->link(t('Collapse tasks'), 'board', 'collapse', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
- </span>
- </li>
- <li>
- <span class="filter-compact">
- <i class="fa fa-th fa-fw"></i> <a href="#" class="filter-toggle-scrolling" title="<?= t('Keyboard shortcut: "%s"', 'c') ?>"><?= t('Compact view') ?></a>
- </span>
- <span class="filter-wide" style="display: none">
- <i class="fa fa-arrows-h fa-fw"></i> <a href="#" class="filter-toggle-scrolling" title="<?= t('Keyboard shortcut: "%s"', 'c') ?>"><?= t('Horizontal scrolling') ?></a>
- </span>
- </li>
- <li>
- <span class="filter-max-height" style="display: none">
- <i class="fa fa-arrows-v fa-fw"></i> <a href="#" class="filter-toggle-height"><?= t('Set maximum column height') ?></a>
- </span>
- <span class="filter-min-height">
- <i class="fa fa-arrows-v fa-fw"></i> <a href="#" class="filter-toggle-height"><?= t('Remove maximum column height') ?></a>
- </span>
- </li>
- <?php endif ?>
- <?= $this->render('project/dropdown', array('project' => $project)) ?>
- </ul>
- </div>
- <ul class="views">
- <li <?= $filters['controller'] === 'board' ? 'class="active"' : '' ?>>
- <i class="fa fa-th fa-fw"></i>
- <?= $this->url->link(t('Board'), 'board', 'show', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-board', t('Keyboard shortcut: "%s"', 'v b')) ?>
- </li>
- <li <?= $filters['controller'] === 'calendar' ? 'class="active"' : '' ?>>
- <i class="fa fa-calendar fa-fw"></i>
- <?= $this->url->link(t('Calendar'), 'calendar', 'show', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-calendar', t('Keyboard shortcut: "%s"', 'v c')) ?>
- </li>
- <li <?= $filters['controller'] === 'listing' ? 'class="active"' : '' ?>>
- <i class="fa fa-list fa-fw"></i>
- <?= $this->url->link(t('List'), 'listing', 'show', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-listing', t('Keyboard shortcut: "%s"', 'v l')) ?>
- </li>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
- <li <?= $filters['controller'] === 'gantt' ? 'class="active"' : '' ?>>
- <i class="fa fa-sliders fa-fw"></i>
- <?= $this->url->link(t('Gantt'), 'gantt', 'project', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-gantt', t('Keyboard shortcut: "%s"', 'v g')) ?>
- </li>
- <?php endif ?>
- </ul>
- <form method="get" action="<?= $this->url->dir() ?>" class="search">
- <?= $this->form->hidden('controller', $filters) ?>
- <?= $this->form->hidden('action', $filters) ?>
- <?= $this->form->hidden('project_id', $filters) ?>
- <?= $this->form->text('search', $filters, array(), array('placeholder="'.t('Filter').'"'), 'form-input-large') ?>
- </form>
-
- <div class="filter-dropdowns">
- <?= $this->render('app/filters_helper', array('reset' => 'status:open')) ?>
-
- <?php if (isset($custom_filters_list) && ! empty($custom_filters_list)): ?>
- <div class="dropdown filters">
- <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('My filters') ?></a>
- <ul>
- <?php foreach ($custom_filters_list as $filter): ?>
- <li><a href="#" class="filter-helper" data-<?php if ($filter['append']): ?><?= 'append-' ?><?php endif ?>filter='<?= $this->e($filter['filter']) ?>'><?= $this->e($filter['name']) ?></a></li>
- <?php endforeach ?>
- </ul>
- </div>
- <?php endif ?>
-
- <?php if (isset($users_list)): ?>
- <div class="dropdown filters">
- <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Users') ?></a>
- <ul>
- <li><a href="#" class="filter-helper" data-append-filter="assignee:nobody"><?= t('Not assigned') ?></a></li>
- <?php foreach ($users_list as $user): ?>
- <li><a href="#" class="filter-helper" data-append-filter='assignee:"<?= $this->e($user) ?>"'><?= $this->e($user) ?></a></li>
- <?php endforeach ?>
- </ul>
- </div>
- <?php endif ?>
-
- <?php if (isset($categories_list) && ! empty($categories_list)): ?>
- <div class="dropdown filters">
- <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Categories') ?></a>
- <ul>
- <li><a href="#" class="filter-helper" data-append-filter="category:none"><?= t('No category') ?></a></li>
- <?php foreach ($categories_list as $category): ?>
- <li><a href="#" class="filter-helper" data-append-filter='category:"<?= $this->e($category) ?>"'><?= $this->e($category) ?></a></li>
- <?php endforeach ?>
- </ul>
- </div>
- <?php endif ?>
- </div>
-</div> \ No newline at end of file
diff --git a/app/Template/project/index.php b/app/Template/project/index.php
index 4b62a27f..8d384e58 100644
--- a/app/Template/project/index.php
+++ b/app/Template/project/index.php
@@ -1,12 +1,10 @@
<section id="main">
<div class="page-header">
<ul>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
- <li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New project'), 'project', 'create') ?></li>
- <?php endif ?>
- <li><i class="fa fa-lock fa-fw"></i><?= $this->url->link(t('New private project'), 'project', 'create', array('private' => 1)) ?></li>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('projectuser', 'managers')): ?>
<li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('Users overview'), 'projectuser', 'managers') ?></li>
+ <?php endif ?>
+ <?php if ($this->user->hasAccess('gantt', 'projects')): ?>
<li><i class="fa fa-sliders fa-fw"></i><?= $this->url->link(t('Projects Gantt chart'), 'gantt', 'projects') ?></li>
<?php endif ?>
</ul>
@@ -21,9 +19,9 @@
<th class="column-15"><?= $paginator->order(t('Project'), 'name') ?></th>
<th class="column-8"><?= $paginator->order(t('Start date'), 'start_date') ?></th>
<th class="column-8"><?= $paginator->order(t('End date'), 'end_date') ?></th>
- <?php if ($this->user->isAdmin() || $this->user->isProjectAdmin()): ?>
- <th class="column-12"><?= t('Managers') ?></th>
- <th class="column-12"><?= t('Members') ?></th>
+ <th class="column-15"><?= $paginator->order(t('Owner'), 'owner_id') ?></th>
+ <?php if ($this->user->hasAccess('projectuser', 'managers')): ?>
+ <th class="column-10"><?= t('Users') ?></th>
<?php endif ?>
<th><?= t('Columns') ?></th>
</tr>
@@ -59,30 +57,21 @@
<?= $this->url->link($this->e($project['name']), 'project', 'show', array('project_id' => $project['id'])) ?>
</td>
<td>
- <?= $project['start_date'] ?>
- </td>
- <td>
- <?= $project['end_date'] ?>
+ <?= $this->dt->date($project['start_date']) ?>
</td>
- <?php if ($this->user->isAdmin() || $this->user->isProjectAdmin()): ?>
<td>
- <ul class="no-bullet">
- <?php foreach ($project['managers'] as $user_id => $user_name): ?>
- <li><?= $this->url->link($this->e($user_name), 'projectuser', 'opens', array('user_id' => $user_id)) ?></li>
- <?php endforeach ?>
- </ul>
+ <?= $this->dt->date($project['end_date']) ?>
</td>
<td>
- <?php if ($project['is_everybody_allowed'] == 1): ?>
- <?= t('Everybody') ?>
- <?php else: ?>
- <ul class="no-bullet">
- <?php foreach ($project['members'] as $user_id => $user_name): ?>
- <li><?= $this->url->link($this->e($user_name), 'projectuser', 'opens', array('user_id' => $user_id)) ?></li>
- <?php endforeach ?>
- </ul>
+ <?php if ($project['owner_id'] > 0): ?>
+ <?= $this->e($project['owner_name'] ?: $project['owner_username']) ?>
<?php endif ?>
</td>
+ <?php if ($this->user->hasAccess('projectuser', 'managers')): ?>
+ <td>
+ <i class="fa fa-users fa-fw"></i>
+ <a href="#" class="tooltip" title="<?= t('Members') ?>" data-href="<?= $this->url->href('Projectuser', 'users', array('project_id' => $project['id'])) ?>"><?= t('Members') ?></a>
+ </td>
<?php endif ?>
<td class="dashboard-project-stats">
<?php foreach ($project['columns'] as $column): ?>
diff --git a/app/Template/project/integrations.php b/app/Template/project/integrations.php
index c4d9385b..54720c69 100644
--- a/app/Template/project/integrations.php
+++ b/app/Template/project/integrations.php
@@ -5,23 +5,11 @@
<form method="post" action="<?= $this->url->href('project', 'integrations', array('project_id' => $project['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
- <?= $this->hook->render('template:project:integrations', array('values' => $values)) ?>
+ <?php $integrations = $this->hook->render('template:project:integrations', array('project' => $project, 'values' => $values, 'webhook_token' => $webhook_token)) ?>
- <h3><i class="fa fa-github fa-fw"></i>&nbsp;<?= t('Github webhooks') ?></h3>
- <div class="listing">
- <input type="text" class="auto-select" readonly="readonly" value="<?= $this->url->href('webhook', 'github', array('token' => $webhook_token, 'project_id' => $project['id']), false, '', true) ?>"/><br/>
- <p class="form-help"><?= $this->url->doc(t('Help on Github webhooks'), 'github-webhooks') ?></p>
- </div>
-
- <h3><img src="<?= $this->url->dir() ?>assets/img/gitlab-icon.png"/>&nbsp;<?= t('Gitlab webhooks') ?></h3>
- <div class="listing">
- <input type="text" class="auto-select" readonly="readonly" value="<?= $this->url->href('webhook', 'gitlab', array('token' => $webhook_token, 'project_id' => $project['id']), false, '', true) ?>"/><br/>
- <p class="form-help"><?= $this->url->doc(t('Help on Gitlab webhooks'), 'gitlab-webhooks') ?></p>
- </div>
-
- <h3><i class="fa fa-bitbucket fa-fw"></i>&nbsp;<?= t('Bitbucket webhooks') ?></h3>
- <div class="listing">
- <input type="text" class="auto-select" readonly="readonly" value="<?= $this->url->href('webhook', 'bitbucket', array('token' => $webhook_token, 'project_id' => $project['id']), false, '', true) ?>"/><br/>
- <p class="form-help"><?= $this->url->doc(t('Help on Bitbucket webhooks'), 'bitbucket-webhooks') ?></p>
- </div>
+ <?php if (empty($integrations)): ?>
+ <p class="alert"><?= t('There is no integration registered at the moment.') ?></p>
+ <?php else: ?>
+ <?= $integrations ?>
+ <?php endif ?>
</form> \ No newline at end of file
diff --git a/app/Template/project/layout.php b/app/Template/project/layout.php
index 8ba92ef9..eb391ae5 100644
--- a/app/Template/project/layout.php
+++ b/app/Template/project/layout.php
@@ -30,7 +30,7 @@
<?= $this->render($sidebar_template, array('project' => $project)) ?>
<div class="sidebar-content">
- <?= $project_content_for_layout ?>
+ <?= $content_for_sublayout ?>
</div>
</section>
</section> \ No newline at end of file
diff --git a/app/Template/project/new.php b/app/Template/project/new.php
deleted file mode 100644
index 8e4ccfec..00000000
--- a/app/Template/project/new.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<section id="main">
- <div class="page-header">
- <ul>
- <li><i class="fa fa-folder fa-fw"></i><?= $this->url->link(t('All projects'), 'project', 'index') ?></li>
- </ul>
- </div>
- <form method="post" action="<?= $this->url->href('project', 'save') ?>" autocomplete="off">
-
- <?= $this->form->csrf() ?>
- <?= $this->form->hidden('is_private', $values) ?>
- <?= $this->form->label(t('Name'), 'name') ?>
- <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- <?= t('or') ?> <?= $this->url->link(t('cancel'), 'project', 'index') ?>
- </div>
- </form>
- <?php if (isset($is_private) && $is_private): ?>
- <div class="alert alert-info">
- <p><?= t('There is no user management for private projects.') ?></p>
- </div>
- <?php endif ?>
-</section> \ No newline at end of file
diff --git a/app/Template/project/notifications.php b/app/Template/project/notifications.php
index ac743087..b39d6c05 100644
--- a/app/Template/project/notifications.php
+++ b/app/Template/project/notifications.php
@@ -2,7 +2,7 @@
<h2><?= t('Notifications') ?></h2>
</div>
<?php if (empty($types)): ?>
- <p class="alert"><?= t('There is no notification method registered.') ?></p>
+ <p class="alert"><?= t('No plugin has registered a project notification method. You can still configure individual notifications in your user profile.') ?></p>
<?php else: ?>
<form method="post" action="<?= $this->url->href('project', 'notifications', array('project_id' => $project['id'])) ?>" autocomplete="off">
diff --git a/app/Template/project/show.php b/app/Template/project/show.php
index 5a65a26e..166b8902 100644
--- a/app/Template/project/show.php
+++ b/app/Template/project/show.php
@@ -4,6 +4,10 @@
<ul class="listing">
<li><strong><?= $project['is_active'] ? t('Active') : t('Inactive') ?></strong></li>
+ <?php if ($project['owner_id'] > 0): ?>
+ <li><?= t('Project owner: ') ?><strong><?= $this->e($project['owner_name'] ?: $project['owner_username']) ?></strong></li>
+ <?php endif ?>
+
<?php if ($project['is_private']): ?>
<li><i class="fa fa-lock"></i> <?= t('This project is private') ?></li>
<?php endif ?>
@@ -17,15 +21,15 @@
<?php endif ?>
<?php if ($project['last_modified']): ?>
- <li><?= dt('Last modified on %B %e, %Y at %k:%M %p', $project['last_modified']) ?></li>
+ <li><?= t('Modified:').' '.$this->dt->datetime($project['last_modified']) ?></li>
<?php endif ?>
<?php if ($project['start_date']): ?>
- <li><?= t('Start date: %s', $project['start_date']) ?></li>
+ <li><?= t('Start date: ').$this->dt->date($project['start_date']) ?></li>
<?php endif ?>
<?php if ($project['end_date']): ?>
- <li><?= t('End date: %s', $project['end_date']) ?></li>
+ <li><?= t('End date: ').$this->dt->date($project['end_date']) ?></li>
<?php endif ?>
<?php if ($stats['nb_tasks'] > 0): ?>
diff --git a/app/Template/project/sidebar.php b/app/Template/project/sidebar.php
index fb5dd3bd..304b4aee 100644
--- a/app/Template/project/sidebar.php
+++ b/app/Template/project/sidebar.php
@@ -1,65 +1,66 @@
<div class="sidebar">
<h2><?= t('Actions') ?></h2>
<ul>
- <li <?= $this->app->getRouterAction() === 'show' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('project', 'show') ?>>
<?= $this->url->link(t('Summary'), 'project', 'show', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'customfilter' && $this->app->getRouterAction() === 'index' ? 'class="active"' : '' ?>>
+ <?php if ($this->user->hasProjectAccess('customfilter', 'index', $project['id'])): ?>
+ <li <?= $this->app->checkMenuSelection('customfilter') ?>>
<?= $this->url->link(t('Custom filters'), 'customfilter', 'index', array('project_id' => $project['id'])) ?>
</li>
+ <?php endif ?>
- <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'share' ? 'class="active"' : '' ?>>
+ <?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $project['id'])): ?>
+ <li <?= $this->app->checkMenuSelection('ProjectEdit', 'edit') ?>>
+ <?= $this->url->link(t('Edit project'), 'ProjectEdit', 'edit', array('project_id' => $project['id'])) ?>
+ </li>
+ <li <?= $this->app->checkMenuSelection('project', 'share') ?>>
<?= $this->url->link(t('Public access'), 'project', 'share', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'notifications' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('project', 'notifications') ?>>
<?= $this->url->link(t('Notifications'), 'project', 'notifications', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'integrations' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('project', 'integrations') ?>>
<?= $this->url->link(t('Integrations'), 'project', 'integrations', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'edit' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Edit project'), 'project', 'edit', array('project_id' => $project['id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'column' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('column') ?>>
<?= $this->url->link(t('Columns'), 'column', 'index', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'swimlane' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('swimlane') ?>>
<?= $this->url->link(t('Swimlanes'), 'swimlane', 'index', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'category' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('category') ?>>
<?= $this->url->link(t('Categories'), 'category', 'index', array('project_id' => $project['id'])) ?>
</li>
- <?php if ($this->user->isAdmin() || $project['is_private'] == 0): ?>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'users' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Users'), 'project', 'users', array('project_id' => $project['id'])) ?>
+ <?php if ($project['is_private'] == 0): ?>
+ <li <?= $this->app->checkMenuSelection('ProjectPermission') ?>>
+ <?= $this->url->link(t('Permissions'), 'ProjectPermission', 'index', array('project_id' => $project['id'])) ?>
</li>
<?php endif ?>
- <li <?= $this->app->getRouterController() === 'action' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('action') ?>>
<?= $this->url->link(t('Automatic actions'), 'action', 'index', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'duplicate' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('project', 'duplicate') ?>>
<?= $this->url->link(t('Duplicate'), 'project', 'duplicate', array('project_id' => $project['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'project' && ($this->app->getRouterAction() === 'disable' || $this->app->getRouterAction() === 'enable') ? 'class="active"' : '' ?>>
<?php if ($project['is_active']): ?>
+ <li <?= $this->app->checkMenuSelection('project', 'disable') ?>>
<?= $this->url->link(t('Disable'), 'project', 'disable', array('project_id' => $project['id']), true) ?>
<?php else: ?>
+ <li <?= $this->app->checkMenuSelection('project', 'enable') ?>>
<?= $this->url->link(t('Enable'), 'project', 'enable', array('project_id' => $project['id']), true) ?>
<?php endif ?>
</li>
- <li <?= $this->app->getRouterController() === 'taskImport' && $this->app->getRouterAction() === 'step1' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('taskImport') ?>>
<?= $this->url->link(t('Import'), 'taskImport', 'step1', array('project_id' => $project['id'])) ?>
</li>
- <?php if ($this->user->isProjectAdministrationAllowed($project['id'])): ?>
- <li <?= $this->app->getRouterController() === 'project' && $this->app->getRouterAction() === 'remove' ? 'class="active"' : '' ?>>
+ <?php if ($this->user->hasProjectAccess('project', 'remove', $project['id'])): ?>
+ <li <?= $this->app->checkMenuSelection('project', 'remove') ?>>
<?= $this->url->link(t('Remove'), 'project', 'remove', array('project_id' => $project['id'])) ?>
</li>
<?php endif ?>
<?php endif ?>
- <?= $this->hook->render('template:project:sidebar') ?>
+ <?= $this->hook->render('template:project:sidebar', array('project' => $project)) ?>
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
</div>
diff --git a/app/Template/project/users.php b/app/Template/project/users.php
deleted file mode 100644
index 8863a1e4..00000000
--- a/app/Template/project/users.php
+++ /dev/null
@@ -1,82 +0,0 @@
-<div class="page-header">
- <h2><?= t('List of authorized users') ?></h2>
-</div>
-
-<?php if ($project['is_everybody_allowed']): ?>
- <div class="alert"><?= t('Everybody have access to this project.') ?></div>
-<?php else: ?>
-
- <?php if (empty($users['allowed'])): ?>
- <div class="alert alert-error"><?= t('Nobody have access to this project.') ?></div>
- <?php else: ?>
- <table>
- <tr>
- <th><?= t('User') ?></th>
- <th><?= t('Role for this project') ?></th>
- <?php if ($project['is_private'] == 0): ?>
- <th><?= t('Actions') ?></th>
- <?php endif ?>
- </tr>
- <?php foreach ($users['allowed'] as $user_id => $username): ?>
- <tr>
- <td><?= $this->e($username) ?></td>
- <td><?= isset($users['managers'][$user_id]) ? t('Project manager') : t('Project member') ?></td>
- <?php if ($project['is_private'] == 0): ?>
- <td>
- <ul>
- <li><?= $this->url->link(t('Revoke'), 'project', 'revoke', array('project_id' => $project['id'], 'user_id' => $user_id), true) ?></li>
- <li>
- <?php if (isset($users['managers'][$user_id])): ?>
- <?= $this->url->link(t('Set project member'), 'project', 'role', array('project_id' => $project['id'], 'user_id' => $user_id, 'is_owner' => 0), true) ?>
- <?php else: ?>
- <?= $this->url->link(t('Set project manager'), 'project', 'role', array('project_id' => $project['id'], 'user_id' => $user_id, 'is_owner' => 1), true) ?>
- <?php endif ?>
- </li>
- </ul>
- </td>
- <?php endif ?>
- </tr>
- <?php endforeach ?>
- </table>
- <?php endif ?>
-
- <?php if ($project['is_private'] == 0 && ! empty($users['not_allowed'])): ?>
- <hr/>
- <form method="post" action="<?= $this->url->href('project', 'allow', array('project_id' => $project['id'])) ?>" autocomplete="off">
-
- <?= $this->form->csrf() ?>
-
- <?= $this->form->hidden('project_id', array('project_id' => $project['id'])) ?>
-
- <?= $this->form->label(t('User'), 'user_id') ?>
- <?= $this->form->select('user_id', $users['not_allowed'], array(), array(), array('data-notfound="'.t('No results match:').'"'), 'chosen-select') ?><br/>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Allow this user') ?>" class="btn btn-blue"/>
- </div>
- </form>
- <?php endif ?>
-
-<?php endif ?>
-
-<?php if ($project['is_private'] == 0): ?>
-<hr/>
-<form method="post" action="<?= $this->url->href('project', 'allowEverybody', array('project_id' => $project['id'])) ?>">
- <?= $this->form->csrf() ?>
-
- <?= $this->form->hidden('id', array('id' => $project['id'])) ?>
- <?= $this->form->checkbox('is_everybody_allowed', t('Allow everybody to access to this project'), 1, $project['is_everybody_allowed']) ?>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- </div>
-</form>
-<?php endif ?>
-
-<div class="alert alert-info">
- <ul>
- <li><?= t('A project manager can change the settings of the project and have more privileges than a standard user.') ?></li>
- <li><?= t('Don\'t forget that administrators have access to everything.') ?></li>
- <li><?= $this->url->doc(t('Help with project permissions'), 'project-permissions') ?></li>
- </ul>
-</div>
diff --git a/app/Template/project_creation/create.php b/app/Template/project_creation/create.php
new file mode 100644
index 00000000..46ec5d1e
--- /dev/null
+++ b/app/Template/project_creation/create.php
@@ -0,0 +1,42 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= $title ?></h2>
+ </div>
+ <form class="popover-form" id="project-creation-form" method="post" action="<?= $this->url->href('ProjectCreation', 'save') ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('is_private', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
+
+ <?php if (count($projects_list) > 1): ?>
+ <?= $this->form->label(t('Create from another project'), 'src_project_id') ?>
+ <?= $this->form->select('src_project_id', $projects_list, $values) ?>
+ <?php endif ?>
+
+ <div class="project-creation-options" <?= isset($values['src_project_id']) && $values['src_project_id'] > 0 ? '' : 'style="display: none"' ?>>
+ <p class="alert"><?= t('Which parts of the project do you want to duplicate?') ?></p>
+
+ <?php if (! $is_private): ?>
+ <?= $this->form->checkbox('projectPermission', t('Permissions'), 1, true) ?>
+ <?php endif ?>
+
+ <?= $this->form->checkbox('category', t('Categories'), 1, true) ?>
+ <?= $this->form->checkbox('action', t('Actions'), 1, true) ?>
+ <?= $this->form->checkbox('swimlane', t('Swimlanes'), 1, true) ?>
+ <?= $this->form->checkbox('task', t('Tasks'), 1, false) ?>
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'project', 'index', array(), false, 'close-popover') ?>
+ </div>
+ </form>
+ <?php if ($is_private): ?>
+ <div class="alert alert-info">
+ <p><?= t('There is no user management for private projects.') ?></p>
+ </div>
+ <?php endif ?>
+</section> \ No newline at end of file
diff --git a/app/Template/project_edit/dates.php b/app/Template/project_edit/dates.php
new file mode 100644
index 00000000..cb585c6a
--- /dev/null
+++ b/app/Template/project_edit/dates.php
@@ -0,0 +1,26 @@
+<div class="page-header">
+ <h2><?= t('Edit project') ?></h2>
+ <ul>
+ <li ><?= $this->url->link(t('General'), 'ProjectEdit', 'edit', array('project_id' => $project['id'])) ?></li>
+ <li class="active"><?= $this->url->link(t('Dates'), 'ProjectEdit', 'dates', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Description'), 'ProjectEdit', 'description', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Task priority'), 'ProjectEdit', 'priority', array('project_id' => $project['id'])) ?></li>
+ </ul>
+</div>
+<form method="post" action="<?= $this->url->href('ProjectEdit', 'update', array('project_id' => $project['id'], 'redirect' => 'dates')) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('name', $values) ?>
+
+ <?= $this->form->label(t('Start date'), 'start_date') ?>
+ <?= $this->form->text('start_date', $values, $errors, array('maxlength="10"'), 'form-date') ?>
+
+ <?= $this->form->label(t('End date'), 'end_date') ?>
+ <?= $this->form->text('end_date', $values, $errors, array('maxlength="10"'), 'form-date') ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form>
+
+<p class="alert alert-info"><?= t('Those dates are useful for the project Gantt chart.') ?></p>
diff --git a/app/Template/project_edit/description.php b/app/Template/project_edit/description.php
new file mode 100644
index 00000000..dce8ab10
--- /dev/null
+++ b/app/Template/project_edit/description.php
@@ -0,0 +1,37 @@
+<div class="page-header">
+ <h2><?= t('Edit project') ?></h2>
+ <ul>
+ <li><?= $this->url->link(t('General'), 'ProjectEdit', 'edit', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Dates'), 'ProjectEdit', 'dates', array('project_id' => $project['id'])) ?></li>
+ <li class="active"><?= $this->url->link(t('Description'), 'ProjectEdit', 'description', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Task priority'), 'ProjectEdit', 'priority', array('project_id' => $project['id'])) ?></li>
+ </ul>
+</div>
+<form method="post" action="<?= $this->url->href('ProjectEdit', 'update', array('project_id' => $project['id'], 'redirect' => 'description')) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('name', $values) ?>
+
+ <?= $this->form->label(t('Description'), 'description') ?>
+ <div class="form-tabs">
+ <div class="write-area">
+ <?= $this->form->textarea('description', $values, $errors) ?>
+ </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>
+ </li>
+ <li class="form-tab">
+ <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
+ </li>
+ </ul>
+ </div>
+ <div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form>
diff --git a/app/Template/project_edit/general.php b/app/Template/project_edit/general.php
new file mode 100644
index 00000000..28cbb66a
--- /dev/null
+++ b/app/Template/project_edit/general.php
@@ -0,0 +1,36 @@
+<div class="page-header">
+ <h2><?= t('Edit project') ?></h2>
+ <ul>
+ <li class="active"><?= $this->url->link(t('General'), 'ProjectEdit', 'edit', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Dates'), 'ProjectEdit', 'dates', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Description'), 'ProjectEdit', 'description', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Task priority'), 'ProjectEdit', 'priority', array('project_id' => $project['id'])) ?></li>
+ </ul>
+</div>
+<form method="post" action="<?= $this->url->href('ProjectEdit', 'update', array('project_id' => $project['id'], 'redirect' => 'edit')) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('required', 'maxlength="50"')) ?>
+
+ <?= $this->form->label(t('Identifier'), 'identifier') ?>
+ <?= $this->form->text('identifier', $values, $errors, array('maxlength="50"')) ?>
+ <p class="form-help"><?= t('The project identifier is optional and must be alphanumeric, example: MYPROJECT.') ?></p>
+
+ <hr>
+ <div class="form-inline">
+ <?= $this->form->label(t('Project owner'), 'owner_id') ?>
+ <?= $this->form->select('owner_id', $owners, $values, $errors) ?>
+ </div>
+
+ <?php if ($this->user->hasProjectAccess('ProjectCreation', 'create', $project['id'])): ?>
+ <hr>
+ <?= $this->form->checkbox('is_private', t('Private project'), 1, $project['is_private'] == 1) ?>
+ <p class="form-help"><?= t('Private projects do not have users and groups management.') ?></p>
+ <?php endif ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form>
diff --git a/app/Template/project_edit/task_priority.php b/app/Template/project_edit/task_priority.php
new file mode 100644
index 00000000..e54215b2
--- /dev/null
+++ b/app/Template/project_edit/task_priority.php
@@ -0,0 +1,29 @@
+<div class="page-header">
+ <h2><?= t('Edit project') ?></h2>
+ <ul>
+ <li ><?= $this->url->link(t('General'), 'ProjectEdit', 'edit', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Dates'), 'ProjectEdit', 'dates', array('project_id' => $project['id'])) ?></li>
+ <li><?= $this->url->link(t('Description'), 'ProjectEdit', 'description', array('project_id' => $project['id'])) ?></li>
+ <li class="active"><?= $this->url->link(t('Task priority'), 'ProjectEdit', 'priority', array('project_id' => $project['id'])) ?></li>
+ </ul>
+</div>
+<form method="post" action="<?= $this->url->href('ProjectEdit', 'update', array('project_id' => $project['id'], 'redirect' => 'priority')) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('name', $values) ?>
+
+ <?= $this->form->label(t('Default priority'), 'priority_default') ?>
+ <?= $this->form->number('priority_default', $values, $errors) ?>
+
+ <?= $this->form->label(t('Lowest priority'), 'priority_start') ?>
+ <?= $this->form->number('priority_start', $values, $errors) ?>
+
+ <?= $this->form->label(t('Highest priority'), 'priority_end') ?>
+ <?= $this->form->number('priority_end', $values, $errors) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form>
+
+<p class="alert alert-info"><?= t('If you put zero to the low and high priority, this feature will be disabled.') ?></p>
diff --git a/app/Template/project_file/create.php b/app/Template/project_file/create.php
new file mode 100644
index 00000000..67315285
--- /dev/null
+++ b/app/Template/project_file/create.php
@@ -0,0 +1,33 @@
+<div class="page-header">
+ <h2><?= t('Attach a document') ?></h2>
+</div>
+<div id="file-done" style="display:none">
+ <p class="alert alert-success">
+ <?= t('All files have been uploaded successfully.') ?>
+ <?= $this->url->link(t('View uploaded files'), 'ProjectOverview', 'show', array('project_id' => $project['id'])) ?>
+ </p>
+</div>
+
+<div id="file-error-max-size" style="display:none">
+ <p class="alert alert-error">
+ <?= t('The maximum allowed file size is %sB.', $this->text->bytes($max_size)) ?>
+ <a href="#" id="file-browser"><?= t('Choose files again') ?></a>
+ </p>
+</div>
+
+<div
+ id="file-dropzone"
+ data-max-size="<?= $max_size ?>"
+ data-url="<?= $this->url->href('ProjectFile', 'save', array('project_id' => $project['id'])) ?>">
+ <div id="file-dropzone-inner">
+ <?= t('Drag and drop your files here') ?> <?= t('or') ?> <a href="#" id="file-browser"><?= t('choose files') ?></a>
+ </div>
+</div>
+
+<input type="file" name="files[]" multiple style="display:none" id="file-form-element">
+
+<div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" id="file-upload-button" disabled>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'ProjectOverview', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
+</div>
diff --git a/app/Template/project_file/remove.php b/app/Template/project_file/remove.php
new file mode 100644
index 00000000..4f0ba465
--- /dev/null
+++ b/app/Template/project_file/remove.php
@@ -0,0 +1,15 @@
+<div class="page-header">
+ <h2><?= t('Remove a file') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this file: "%s"?', $this->e($file['name'])) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'ProjectFile', 'remove', array('project_id' => $project['id'], 'file_id' => $file['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'ProjectOverview', 'show', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Template/project_header/dropdown.php b/app/Template/project_header/dropdown.php
new file mode 100644
index 00000000..bbc033bf
--- /dev/null
+++ b/app/Template/project_header/dropdown.php
@@ -0,0 +1,34 @@
+<div class="dropdown">
+ <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Actions') ?></a>
+ <ul>
+ <?php if ($is_board): ?>
+ <li>
+ <span class="filter-display-mode" <?= $this->board->isCollapsed($project['id']) ? '' : 'style="display: none;"' ?>>
+ <i class="fa fa-expand fa-fw"></i>
+ <?= $this->url->link(t('Expand tasks'), 'board', 'expand', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
+ </span>
+ <span class="filter-display-mode" <?= $this->board->isCollapsed($project['id']) ? 'style="display: none;"' : '' ?>>
+ <i class="fa fa-compress fa-fw"></i>
+ <?= $this->url->link(t('Collapse tasks'), 'board', 'collapse', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
+ </span>
+ </li>
+ <li>
+ <span class="filter-compact">
+ <i class="fa fa-th fa-fw"></i> <a href="#" class="filter-toggle-scrolling" title="<?= t('Keyboard shortcut: "%s"', 'c') ?>"><?= t('Compact view') ?></a>
+ </span>
+ <span class="filter-wide" style="display: none">
+ <i class="fa fa-arrows-h fa-fw"></i> <a href="#" class="filter-toggle-scrolling" title="<?= t('Keyboard shortcut: "%s"', 'c') ?>"><?= t('Horizontal scrolling') ?></a>
+ </span>
+ </li>
+ <li>
+ <span class="filter-max-height" style="display: none">
+ <i class="fa fa-arrows-v fa-fw"></i> <a href="#" class="filter-toggle-height"><?= t('Set maximum column height') ?></a>
+ </span>
+ <span class="filter-min-height">
+ <i class="fa fa-arrows-v fa-fw"></i> <a href="#" class="filter-toggle-height"><?= t('Remove maximum column height') ?></a>
+ </span>
+ </li>
+ <?php endif ?>
+ <?= $this->render('project/dropdown', array('project' => $project)) ?>
+ </ul>
+</div> \ No newline at end of file
diff --git a/app/Template/project_header/header.php b/app/Template/project_header/header.php
new file mode 100644
index 00000000..f6e5af9e
--- /dev/null
+++ b/app/Template/project_header/header.php
@@ -0,0 +1,15 @@
+<div class="project-header">
+ <?= $this->hook->render('template:project:header:before', array('project' => $project)) ?>
+
+ <?= $this->render('project_header/dropdown', array('project' => $project, 'is_board' => isset($is_board))) ?>
+ <?= $this->render('project_header/views', array('project' => $project, 'filters' => $filters)) ?>
+ <?= $this->render('project_header/search', array(
+ 'project' => $project,
+ 'filters' => $filters,
+ 'custom_filters_list' => isset($custom_filters_list) ? $custom_filters_list : array(),
+ 'users_list' => isset($users_list) ? $users_list : array(),
+ 'categories_list' => isset($categories_list) ? $categories_list : array(),
+ )) ?>
+
+ <?= $this->hook->render('template:project:header:after', array('project' => $project)) ?>
+</div> \ No newline at end of file
diff --git a/app/Template/project_header/search.php b/app/Template/project_header/search.php
new file mode 100644
index 00000000..2b2a2c39
--- /dev/null
+++ b/app/Template/project_header/search.php
@@ -0,0 +1,45 @@
+<div class="filter-box">
+ <form method="get" action="<?= $this->url->dir() ?>" class="search">
+ <?= $this->form->hidden('controller', $filters) ?>
+ <?= $this->form->hidden('action', $filters) ?>
+ <?= $this->form->hidden('project_id', $filters) ?>
+ <?= $this->form->text('search', $filters, array(), array('placeholder="'.t('Filter').'"')) ?>
+
+ <?= $this->render('app/filters_helper', array('reset' => 'status:open', 'project' => $project)) ?>
+
+ <?php if (isset($custom_filters_list) && ! empty($custom_filters_list)): ?>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon" title="<?= t('Custom filters') ?>"><i class="fa fa-bookmark fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <?php foreach ($custom_filters_list as $filter): ?>
+ <li><a href="#" class="filter-helper" data-<?php if ($filter['append']): ?><?= 'append-' ?><?php endif ?>filter='<?= $this->e($filter['filter']) ?>'><?= $this->e($filter['name']) ?></a></li>
+ <?php endforeach ?>
+ </ul>
+ </div>
+ <?php endif ?>
+
+ <?php if (isset($users_list)): ?>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon" title="<?= t('User filters') ?>"><i class="fa fa-users fa-fw"></i> <i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li><a href="#" class="filter-helper" data-append-filter="assignee:nobody"><?= t('Not assigned') ?></a></li>
+ <?php foreach ($users_list as $user): ?>
+ <li><a href="#" class="filter-helper" data-append-filter='assignee:"<?= $this->e($user) ?>"'><?= $this->e($user) ?></a></li>
+ <?php endforeach ?>
+ </ul>
+ </div>
+ <?php endif ?>
+
+ <?php if (isset($categories_list) && ! empty($categories_list)): ?>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon" title="<?= t('Category filters') ?>"><i class="fa fa-tags fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li><a href="#" class="filter-helper" data-append-filter="category:none"><?= t('No category') ?></a></li>
+ <?php foreach ($categories_list as $category): ?>
+ <li><a href="#" class="filter-helper" data-append-filter='category:"<?= $this->e($category) ?>"'><?= $this->e($category) ?></a></li>
+ <?php endforeach ?>
+ </ul>
+ </div>
+ <?php endif ?>
+ </form>
+</div>
diff --git a/app/Template/project_header/views.php b/app/Template/project_header/views.php
new file mode 100644
index 00000000..f8fdbb02
--- /dev/null
+++ b/app/Template/project_header/views.php
@@ -0,0 +1,24 @@
+<ul class="views">
+ <li <?= $this->app->getRouterController() === 'ProjectOverview' ? 'class="active"' : '' ?>>
+ <i class="fa fa-eye fa-fw"></i>
+ <?= $this->url->link(t('Overview'), 'ProjectOverview', 'show', array('project_id' => $project['id']), false, 'view-overview', t('Keyboard shortcut: "%s"', 'v o')) ?>
+ </li>
+ <li <?= $this->app->getRouterController() === 'Board' ? 'class="active"' : '' ?>>
+ <i class="fa fa-th fa-fw"></i>
+ <?= $this->url->link(t('Board'), 'board', 'show', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-board', t('Keyboard shortcut: "%s"', 'v b')) ?>
+ </li>
+ <li <?= $this->app->getRouterController() === 'Calendar' ? 'class="active"' : '' ?>>
+ <i class="fa fa-calendar fa-fw"></i>
+ <?= $this->url->link(t('Calendar'), 'calendar', 'show', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-calendar', t('Keyboard shortcut: "%s"', 'v c')) ?>
+ </li>
+ <li <?= $this->app->getRouterController() === 'Listing' ? 'class="active"' : '' ?>>
+ <i class="fa fa-list fa-fw"></i>
+ <?= $this->url->link(t('List'), 'listing', 'show', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-listing', t('Keyboard shortcut: "%s"', 'v l')) ?>
+ </li>
+ <?php if ($this->user->hasProjectAccess('gantt', 'project', $project['id'])): ?>
+ <li <?= $this->app->getRouterController() === 'Gantt' ? 'class="active"' : '' ?>>
+ <i class="fa fa-sliders fa-fw"></i>
+ <?= $this->url->link(t('Gantt'), 'gantt', 'project', array('project_id' => $project['id'], 'search' => $filters['search']), false, 'view-gantt', t('Keyboard shortcut: "%s"', 'v g')) ?>
+ </li>
+ <?php endif ?>
+</ul> \ No newline at end of file
diff --git a/app/Template/project_overview/columns.php b/app/Template/project_overview/columns.php
new file mode 100644
index 00000000..870d753f
--- /dev/null
+++ b/app/Template/project_overview/columns.php
@@ -0,0 +1,8 @@
+<div class="project-overview-columns">
+ <?php foreach ($project['columns'] as $column): ?>
+ <div class="project-overview-column">
+ <strong title="<?= t('Task count') ?>"><?= $column['nb_tasks'] ?></strong><br>
+ <span><?= $this->e($column['title']) ?></span>
+ </div>
+ <?php endforeach ?>
+</div>
diff --git a/app/Template/project_overview/description.php b/app/Template/project_overview/description.php
new file mode 100644
index 00000000..4137bf9f
--- /dev/null
+++ b/app/Template/project_overview/description.php
@@ -0,0 +1,8 @@
+<?php if (! empty($project['description'])): ?>
+ <div class="page-header">
+ <h2><?= $this->e($project['name']) ?></h2>
+ </div>
+ <article class="markdown">
+ <?= $this->text->markdown($project['description']) ?>
+ </article>
+<?php endif ?>
diff --git a/app/Template/project_overview/files.php b/app/Template/project_overview/files.php
new file mode 100644
index 00000000..7eb8c762
--- /dev/null
+++ b/app/Template/project_overview/files.php
@@ -0,0 +1,98 @@
+<div class="page-header">
+ <h2><?= t('Attachments') ?></h2>
+ <?php if ($this->user->hasProjectAccess('ProjectFile', 'create', $project['id'])): ?>
+ <ul>
+ <li>
+ <i class="fa fa-plus fa-fw"></i>
+ <?= $this->url->link(t('Upload a file'), 'ProjectFile', 'create', array('project_id' => $project['id']), false, 'popover') ?>
+ </li>
+ </ul>
+ <?php endif ?>
+</div>
+
+<?php if (empty($files) && empty($images)): ?>
+ <p class="alert"><?= t('There is no attachment at the moment.') ?></p>
+<?php endif ?>
+
+<?php if (! empty($images)): ?>
+<div class="file-thumbnails">
+ <?php foreach ($images as $file): ?>
+ <div class="file-thumbnail">
+ <a href="<?= $this->url->href('FileViewer', 'show', array('project_id' => $project['id'], 'file_id' => $file['id'])) ?>" class="popover"><img src="<?= $this->url->href('FileViewer', 'thumbnail', array('file_id' => $file['id'], 'project_id' => $project['id'])) ?>" title="<?= $this->e($file['name']) ?>" alt="<?= $this->e($file['name']) ?>"></a>
+ <div class="file-thumbnail-content">
+ <div class="file-thumbnail-title">
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-text"><?= $this->e($file['name']) ?> <i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <i class="fa fa-download fa-fw"></i>
+ <?= $this->url->link(t('Download'), 'FileViewer', 'download', array('project_id' => $project['id'], 'file_id' => $file['id'])) ?>
+ </li>
+ <?php if ($this->user->hasProjectAccess('ProjectFile', 'remove', $project['id'])): ?>
+ <li>
+ <i class="fa fa-trash fa-fw"></i>
+ <?= $this->url->link(t('Remove'), 'ProjectFile', 'confirm', array('project_id' => $project['id'], 'file_id' => $file['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ </div>
+ <div class="file-thumbnail-description">
+ <span class="tooltip" title='<?= t('Uploaded: %s', $this->dt->datetime($file['date'])).'<br>'.t('Size: %s', $this->text->bytes($file['size'])) ?>'>
+ <i class="fa fa-info-circle"></i>
+ </span>
+ <?= t('Uploaded by %s', $file['user_name'] ?: $file['username']) ?>
+ </div>
+ </div>
+ </div>
+ <?php endforeach ?>
+</div>
+<?php endif ?>
+
+<?php if (! empty($files)): ?>
+<table class="table-stripped">
+ <tr>
+ <th><?= t('Filename') ?></th>
+ <th><?= t('Creator') ?></th>
+ <th><?= t('Date') ?></th>
+ <th><?= t('Size') ?></th>
+ </tr>
+ <?php foreach ($files as $file): ?>
+ <tr>
+ <td>
+ <i class="fa <?= $this->file->icon($file['name']) ?> fa-fw"></i>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-text"><?= $this->e($file['name']) ?> <i class="fa fa-caret-down"></i></a>
+ <ul>
+ <?php if ($this->file->getPreviewType($file['name']) !== null): ?>
+ <li>
+ <i class="fa fa-eye fa-fw"></i>
+ <?= $this->url->link(t('View file'), 'FileViewer', 'show', array('project_id' => $project['id'], 'file_id' => $file['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ <li>
+ <i class="fa fa-download fa-fw"></i>
+ <?= $this->url->link(t('Download'), 'FileViewer', 'download', array('project_id' => $project['id'], 'file_id' => $file['id'])) ?>
+ </li>
+ <?php if ($this->user->hasProjectAccess('ProjectFile', 'remove', $project['id'])): ?>
+ <li>
+ <i class="fa fa-trash fa-fw"></i>
+ <?= $this->url->link(t('Remove'), 'ProjectFile', 'confirm', array('project_id' => $project['id'], 'file_id' => $file['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ </td>
+ <td>
+ <?= $this->e($file['user_name'] ?: $file['username']) ?>
+ </td>
+ <td>
+ <?= $this->dt->date($file['date']) ?>
+ </td>
+ <td>
+ <?= $this->text->bytes($file['size']) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+</table>
+<?php endif ?>
diff --git a/app/Template/project_overview/information.php b/app/Template/project_overview/information.php
new file mode 100644
index 00000000..12a1317d
--- /dev/null
+++ b/app/Template/project_overview/information.php
@@ -0,0 +1,35 @@
+<div class="page-header">
+ <h2><?= t('Information') ?></h2>
+</div>
+<div class="listing">
+<ul>
+ <?php if ($project['owner_id'] > 0): ?>
+ <li><?= t('Project owner: ') ?><strong><?= $this->e($project['owner_name'] ?: $project['owner_username']) ?></strong></li>
+ <?php endif ?>
+
+ <?php if (! empty($users)): ?>
+ <?php foreach ($roles as $role => $role_name): ?>
+ <?php if (isset($users[$role])): ?>
+ <li>
+ <?= $role_name ?>:
+ <strong><?= implode(', ', $users[$role]) ?></strong>
+ </li>
+ <?php endif ?>
+ <?php endforeach ?>
+ <?php endif ?>
+
+ <?php if ($project['start_date']): ?>
+ <li><?= t('Start date: ').$this->dt->date($project['start_date']) ?></li>
+ <?php endif ?>
+
+ <?php if ($project['end_date']): ?>
+ <li><?= t('End date: ').$this->dt->date($project['end_date']) ?></li>
+ <?php endif ?>
+
+ <?php if ($project['is_public']): ?>
+ <li><i class="fa fa-share-alt"></i> <?= $this->url->link(t('Public link'), 'board', 'readonly', array('token' => $project['token']), false, '', '', true) ?></li>
+ <li><i class="fa fa-rss-square"></i> <?= $this->url->link(t('RSS feed'), 'feed', 'project', array('token' => $project['token']), false, '', '', true) ?></li>
+ <li><i class="fa fa-calendar"></i> <?= $this->url->link(t('iCal feed'), 'ical', 'project', array('token' => $project['token'])) ?></li>
+ <?php endif ?>
+</ul>
+</div>
diff --git a/app/Template/project_overview/show.php b/app/Template/project_overview/show.php
new file mode 100644
index 00000000..0038d952
--- /dev/null
+++ b/app/Template/project_overview/show.php
@@ -0,0 +1,16 @@
+<section id="main">
+ <?= $this->render('project_header/header', array(
+ 'project' => $project,
+ 'filters' => $filters,
+ )) ?>
+
+ <?= $this->render('project_overview/columns', array('project' => $project)) ?>
+ <?= $this->render('project_overview/description', array('project' => $project)) ?>
+ <?= $this->render('project_overview/files', array('project' => $project, 'images' => $images, 'files' => $files)) ?>
+ <?= $this->render('project_overview/information', array('project' => $project, 'users' => $users, 'roles' => $roles)) ?>
+
+ <div class="page-header">
+ <h2><?= t('Last activity') ?></h2>
+ </div>
+ <?= $this->render('event/events', array('events' => $events)) ?>
+</section>
diff --git a/app/Template/project_permission/index.php b/app/Template/project_permission/index.php
new file mode 100644
index 00000000..5f0edc2b
--- /dev/null
+++ b/app/Template/project_permission/index.php
@@ -0,0 +1,141 @@
+<div class="page-header">
+ <h2><?= t('Allowed Users') ?></h2>
+</div>
+
+<?php if ($project['is_everybody_allowed']): ?>
+ <div class="alert"><?= t('Everybody have access to this project.') ?></div>
+<?php else: ?>
+
+ <?php if (empty($users)): ?>
+ <div class="alert"><?= t('No user have been allowed specifically.') ?></div>
+ <?php else: ?>
+ <table>
+ <tr>
+ <th class="column-50"><?= t('User') ?></th>
+ <th><?= t('Role') ?></th>
+ <?php if ($project['is_private'] == 0): ?>
+ <th class="column-15"><?= t('Actions') ?></th>
+ <?php endif ?>
+ </tr>
+ <?php foreach ($users as $user): ?>
+ <tr>
+ <td><?= $this->e($user['name'] ?: $user['username']) ?></td>
+ <td>
+ <?= $this->form->select(
+ 'role-'.$user['id'],
+ $roles,
+ array('role-'.$user['id'] => $user['role']),
+ array(),
+ array('data-url="'.$this->url->href('ProjectPermission', 'changeUserRole', array('project_id' => $project['id'])).'"', 'data-id="'.$user['id'].'"'),
+ 'project-change-role'
+ ) ?>
+ </td>
+ <td>
+ <?= $this->url->link(t('Remove'), 'ProjectPermission', 'removeUser', array('project_id' => $project['id'], 'user_id' => $user['id']), true) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+
+ <?php if ($project['is_private'] == 0): ?>
+ <div class="listing">
+ <form method="post" action="<?= $this->url->href('ProjectPermission', 'addUser', array('project_id' => $project['id'])) ?>" autocomplete="off" class="form-inline">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('project_id', array('project_id' => $project['id'])) ?>
+ <?= $this->form->hidden('user_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array(
+ 'required',
+ 'placeholder="'.t('Enter user name...').'"',
+ 'title="'.t('Enter user name...').'"',
+ 'data-dst-field="user_id"',
+ 'data-search-url="'.$this->url->href('UserHelper', 'autocomplete').'"',
+ ),
+ 'autocomplete') ?>
+
+ <?= $this->form->select('role', $roles, $values, $errors) ?>
+
+ <input type="submit" value="<?= t('Add') ?>" class="btn btn-blue"/>
+ </form>
+ </div>
+ <?php endif ?>
+
+ <div class="page-header">
+ <h2><?= t('Allowed Groups') ?></h2>
+ </div>
+
+ <?php if (empty($groups)): ?>
+ <div class="alert"><?= t('No group have been allowed specifically.') ?></div>
+ <?php else: ?>
+ <table>
+ <tr>
+ <th class="column-50"><?= t('Group') ?></th>
+ <th><?= t('Role') ?></th>
+ <?php if ($project['is_private'] == 0): ?>
+ <th class="column-15"><?= t('Actions') ?></th>
+ <?php endif ?>
+ </tr>
+ <?php foreach ($groups as $group): ?>
+ <tr>
+ <td><?= $this->e($group['name']) ?></td>
+ <td>
+ <?= $this->form->select(
+ 'role-'.$group['id'],
+ $roles,
+ array('role-'.$group['id'] => $group['role']),
+ array(),
+ array('data-url="'.$this->url->href('ProjectPermission', 'changeGroupRole', array('project_id' => $project['id'])).'"', 'data-id="'.$group['id'].'"'),
+ 'project-change-role'
+ ) ?>
+ </td>
+ <td>
+ <?= $this->url->link(t('Remove'), 'ProjectPermission', 'removeGroup', array('project_id' => $project['id'], 'group_id' => $group['id']), true) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+
+ <?php if ($project['is_private'] == 0): ?>
+ <div class="listing">
+ <form method="post" action="<?= $this->url->href('ProjectPermission', 'addGroup', array('project_id' => $project['id'])) ?>" autocomplete="off" class="form-inline">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('project_id', array('project_id' => $project['id'])) ?>
+ <?= $this->form->hidden('group_id', $values) ?>
+ <?= $this->form->hidden('external_id', $values) ?>
+
+ <?= $this->form->label(t('Group Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array(
+ 'required',
+ 'placeholder="'.t('Enter group name...').'"',
+ 'title="'.t('Enter group name...').'"',
+ 'data-dst-field="group_id"',
+ 'data-dst-extra-field="external_id"',
+ 'data-search-url="'.$this->url->href('GroupHelper', 'autocomplete').'"',
+ ),
+ 'autocomplete') ?>
+
+ <?= $this->form->select('role', $roles, $values, $errors) ?>
+
+ <input type="submit" value="<?= t('Add') ?>" class="btn btn-blue"/>
+ </form>
+ </div>
+ <?php endif ?>
+
+<?php endif ?>
+
+<?php if ($project['is_private'] == 0): ?>
+<hr/>
+<form method="post" action="<?= $this->url->href('ProjectPermission', 'allowEverybody', array('project_id' => $project['id'])) ?>">
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('id', array('id' => $project['id'])) ?>
+ <?= $this->form->checkbox('is_everybody_allowed', t('Allow everybody to access to this project'), 1, $project['is_everybody_allowed']) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form>
+<?php endif ?>
diff --git a/app/Template/project_user/layout.php b/app/Template/project_user/layout.php
index 4cf732d6..3ced5590 100644
--- a/app/Template/project_user/layout.php
+++ b/app/Template/project_user/layout.php
@@ -1,18 +1,11 @@
<section id="main">
<div class="page-header">
<ul>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
- <li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New project'), 'project', 'create') ?></li>
- <?php endif ?>
- <li>
- <i class="fa fa-lock fa-fw"></i>
- <?= $this->url->link(t('New private project'), 'project', 'create', array('private' => 1)) ?>
- </li>
<li>
<i class="fa fa-folder fa-fw"></i>
<?= $this->url->link(t('Projects list'), 'project', 'index') ?>
</li>
- <?php if ($this->user->isProjectAdmin() || $this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('gantt', 'projects')): ?>
<li>
<i class="fa fa-sliders fa-fw"></i>
<?= $this->url->link(t('Projects Gantt chart'), 'gantt', 'projects') ?>
@@ -22,7 +15,7 @@
</div>
<section class="sidebar-container">
- <?= $this->render('project_user/sidebar', array('users' => $users, 'filter' => $filter)) ?>
+ <?= $this->render($sidebar_template, array('users' => $users, 'filter' => $filter)) ?>
<div class="sidebar-content">
<div class="page-header">
diff --git a/app/Template/project_user/sidebar.php b/app/Template/project_user/sidebar.php
index b81ba14a..ff113ebb 100644
--- a/app/Template/project_user/sidebar.php
+++ b/app/Template/project_user/sidebar.php
@@ -10,18 +10,18 @@
'chosen-select select-auto-redirect'
) ?>
- <br/><br/>
+ <br><br>
<ul>
- <li <?= $this->app->getRouterAction() === 'managers' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('projectuser', 'managers') ?>>
<?= $this->url->link(t('Project managers'), 'projectuser', 'managers', $filter) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'members' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('projectuser', 'members') ?>>
<?= $this->url->link(t('Project members'), 'projectuser', 'members', $filter) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'opens' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('projectuser', 'opens') ?>>
<?= $this->url->link(t('Open tasks'), 'projectuser', 'opens', $filter) ?>
</li>
- <li <?= $this->app->getRouterAction() === 'closed' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('projectuser', 'closed') ?>>
<?= $this->url->link(t('Closed tasks'), 'projectuser', 'closed', $filter) ?>
</li>
diff --git a/app/Template/project_user/tasks.php b/app/Template/project_user/tasks.php
index f4fc2723..8d1cbd96 100644
--- a/app/Template/project_user/tasks.php
+++ b/app/Template/project_user/tasks.php
@@ -33,10 +33,10 @@
<?php endif ?>
</td>
<td>
- <?= dt('%B %e, %Y', $task['date_started']) ?>
+ <?= $this->dt->date($task['date_started']) ?>
</td>
<td>
- <?= dt('%B %e, %Y', $task['date_due']) ?>
+ <?= $this->dt->date($task['date_due']) ?>
</td>
</tr>
<?php endforeach ?>
diff --git a/app/Template/project_user/tooltip_users.php b/app/Template/project_user/tooltip_users.php
new file mode 100644
index 00000000..7a07caad
--- /dev/null
+++ b/app/Template/project_user/tooltip_users.php
@@ -0,0 +1,14 @@
+<?php if (empty($users)): ?>
+ <p><?= t('There is no project member.') ?></p>
+<?php else: ?>
+ <?php foreach ($roles as $role => $role_name): ?>
+ <?php if (isset($users[$role])): ?>
+ <strong><?= $role_name ?></strong>
+ <ul>
+ <?php foreach ($users[$role] as $user_id => $user): ?>
+ <li><?= $this->url->link($this->e($user), 'Projectuser', 'opens', array('user_id' => $user_id)) ?></li>
+ <?php endforeach ?>
+ </ul>
+ <?php endif ?>
+ <?php endforeach ?>
+<?php endif ?> \ No newline at end of file
diff --git a/app/Template/search/index.php b/app/Template/search/index.php
index 329c072a..9231a6f3 100644
--- a/app/Template/search/index.php
+++ b/app/Template/search/index.php
@@ -8,14 +8,13 @@
</ul>
</div>
- <div class="search">
+ <div class="filter-box">
<form method="get" action="<?= $this->url->dir() ?>" class="search">
<?= $this->form->hidden('controller', $values) ?>
<?= $this->form->hidden('action', $values) ?>
<?= $this->form->text('search', $values, array(), array(empty($values['search']) ? 'autofocus' : '', 'placeholder="'.t('Search').'"'), 'form-input-large') ?>
+ <?= $this->render('app/filters_helper') ?>
</form>
-
- <?= $this->render('app/filters_helper') ?>
</div>
<?php if (empty($values['search'])): ?>
diff --git a/app/Template/search/results.php b/app/Template/search/results.php
index 88eed87c..3bb0e603 100644
--- a/app/Template/search/results.php
+++ b/app/Template/search/results.php
@@ -38,7 +38,7 @@
<?php endif ?>
</td>
<td>
- <?= dt('%B %e, %Y', $task['date_due']) ?>
+ <?= $this->dt->date($task['date_due']) ?>
</td>
<td>
<?php if ($task['is_active'] == \Kanboard\Model\Task::STATUS_OPEN): ?>
diff --git a/app/Template/subtask/create.php b/app/Template/subtask/create.php
index 82e378f5..8fffd3a9 100644
--- a/app/Template/subtask/create.php
+++ b/app/Template/subtask/create.php
@@ -2,26 +2,19 @@
<h2><?= t('Add a sub-task') ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('subtask', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('subtask', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
-
<?= $this->form->hidden('task_id', $values) ?>
-
- <?= $this->form->label(t('Title'), 'title') ?>
- <?= $this->form->text('title', $values, $errors, array('required', 'autofocus', 'maxlength="255"')) ?><br/>
-
- <?= $this->form->label(t('Assignee'), 'user_id') ?>
- <?= $this->form->select('user_id', $users_list, $values, $errors) ?><br/>
-
- <?= $this->form->label(t('Original estimate'), 'time_estimated') ?>
- <?= $this->form->numeric('time_estimated', $values, $errors) ?> <?= t('hours') ?><br/>
+ <?= $this->subtask->selectTitle($values, $errors, array('autofocus')) ?>
+ <?= $this->subtask->selectAssignee($users_list, $values, $errors) ?>
+ <?= $this->subtask->selectTimeEstimated($values, $errors) ?>
<?= $this->form->checkbox('another_subtask', t('Create another sub-task'), 1, isset($values['another_subtask']) && $values['another_subtask'] == 1) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/subtask/edit.php b/app/Template/subtask/edit.php
index 2e583069..acce625e 100644
--- a/app/Template/subtask/edit.php
+++ b/app/Template/subtask/edit.php
@@ -2,28 +2,19 @@
<h2><?= t('Edit a sub-task') ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('subtask', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('subtask', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
-
<?= $this->form->hidden('id', $values) ?>
<?= $this->form->hidden('task_id', $values) ?>
-
- <?= $this->form->label(t('Title'), 'title') ?>
- <?= $this->form->text('title', $values, $errors, array('required', 'autofocus', 'maxlength="255"')) ?><br/>
-
- <?= $this->form->label(t('Assignee'), 'user_id') ?>
- <?= $this->form->select('user_id', $users_list, $values, $errors) ?><br/>
-
- <?= $this->form->label(t('Original estimate'), 'time_estimated') ?>
- <?= $this->form->numeric('time_estimated', $values, $errors) ?> <?= t('hours') ?><br/>
-
- <?= $this->form->label(t('Time spent'), 'time_spent') ?>
- <?= $this->form->numeric('time_spent', $values, $errors) ?> <?= t('hours') ?><br/>
+ <?= $this->subtask->selectTitle($values, $errors, array('autofocus')) ?>
+ <?= $this->subtask->selectAssignee($users_list, $values, $errors) ?>
+ <?= $this->subtask->selectTimeEstimated($values, $errors) ?>
+ <?= $this->subtask->selectTimeSpent($values, $errors) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/subtask/icons.php b/app/Template/subtask/icons.php
deleted file mode 100644
index 1f31d51f..00000000
--- a/app/Template/subtask/icons.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<?php if ($subtask['status'] == 0): ?>
- <i class="fa fa-square-o fa-fw"></i>
-<?php elseif ($subtask['status'] == 1): ?>
- <i class="fa fa-gears fa-fw"></i>
-<?php else: ?>
- <i class="fa fa-check-square-o fa-fw"></i>
-<?php endif ?> \ No newline at end of file
diff --git a/app/Template/subtask/menu.php b/app/Template/subtask/menu.php
new file mode 100644
index 00000000..6c98b951
--- /dev/null
+++ b/app/Template/subtask/menu.php
@@ -0,0 +1,11 @@
+<div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <?= $this->url->link(t('Edit'), 'subtask', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id']), false, 'popover') ?>
+ </li>
+ <li>
+ <?= $this->url->link(t('Remove'), 'subtask', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id']), false, 'popover') ?>
+ </li>
+ </ul>
+</div>
diff --git a/app/Template/subtask/remove.php b/app/Template/subtask/remove.php
index 65ade31d..9aef6842 100644
--- a/app/Template/subtask/remove.php
+++ b/app/Template/subtask/remove.php
@@ -12,6 +12,6 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'subtask', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/subtask/show.php b/app/Template/subtask/show.php
index 1f0f9bba..b0326c48 100644
--- a/app/Template/subtask/show.php
+++ b/app/Template/subtask/show.php
@@ -1,93 +1,12 @@
-<?php if (! empty($subtasks)): ?>
-
-<?php $first_position = $subtasks[0]['position']; ?>
-<?php $last_position = $subtasks[count($subtasks) - 1]['position']; ?>
-
-<div id="subtasks" class="task-show-section">
-
- <div class="page-header">
- <h2><?= t('Sub-Tasks') ?></h2>
- </div>
+<div class="page-header">
+ <h2><?= t('Sub-Tasks') ?></h2>
+</div>
- <table class="subtasks-table">
- <tr>
- <th class="column-40"><?= t('Title') ?></th>
- <th><?= t('Assignee') ?></th>
- <th><?= t('Time tracking') ?></th>
- <?php if (! isset($not_editable)): ?>
- <th><?= t('Actions') ?></th>
- <?php endif ?>
- </tr>
- <?php foreach ($subtasks as $subtask): ?>
- <tr>
- <td>
- <?php if (! isset($not_editable)): ?>
- <?= $this->subtask->toggleStatus($subtask, 'task') ?>
- <?php else: ?>
- <?= $this->render('subtask/icons', array('subtask' => $subtask)) . $this->e($subtask['title']) ?>
- <?php endif ?>
- </td>
- <td>
- <?php if (! empty($subtask['username'])): ?>
- <?php if (! isset($not_editable)): ?>
- <?= $this->url->link($this->e($subtask['name'] ?: $subtask['username']), 'user', 'show', array('user_id' => $subtask['user_id'])) ?>
- <?php else: ?>
- <?= $this->e($subtask['name'] ?: $subtask['username']) ?>
- <?php endif ?>
- <?php endif ?>
- </td>
- <td>
- <ul class="no-bullet">
- <li>
- <?php if (! empty($subtask['time_spent'])): ?>
- <strong><?= $this->e($subtask['time_spent']).'h' ?></strong> <?= t('spent') ?>
- <?php endif ?>
+<div id="subtasks">
- <?php if (! empty($subtask['time_estimated'])): ?>
- <strong><?= $this->e($subtask['time_estimated']).'h' ?></strong> <?= t('estimated') ?>
- <?php endif ?>
- </li>
- <?php if (! isset($not_editable) && $subtask['user_id'] == $this->user->getId()): ?>
- <li>
- <?php if ($subtask['is_timer_started']): ?>
- <i class="fa fa-pause"></i>
- <?= $this->url->link(t('Stop timer'), 'timer', 'subtask', array('timer' => 'stop', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'])) ?>
- (<?= $this->dt->age($subtask['timer_start_date']) ?>)
- <?php else: ?>
- <i class="fa fa-play-circle-o"></i>
- <?= $this->url->link(t('Start timer'), 'timer', 'subtask', array('timer' => 'start', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'])) ?>
- <?php endif ?>
- </li>
- <?php endif ?>
- </ul>
- </td>
- <?php if (! isset($not_editable)): ?>
- <td>
- <ul>
- <?php if ($subtask['position'] != $first_position): ?>
- <li>
- <?= $this->url->link(t('Move Up'), 'subtask', 'movePosition', array('project_id' => $project['id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'direction' => 'up'), true) ?>
- </li>
- <?php endif ?>
- <?php if ($subtask['position'] != $last_position): ?>
- <li>
- <?= $this->url->link(t('Move Down'), 'subtask', 'movePosition', array('project_id' => $project['id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'direction' => 'down'), true) ?>
- </li>
- <?php endif ?>
- <li>
- <?= $this->url->link(t('Edit'), 'subtask', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id'])) ?>
- </li>
- <li>
- <?= $this->url->link(t('Remove'), 'subtask', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id'])) ?>
- </li>
- </ul>
- </td>
- <?php endif ?>
- </tr>
- <?php endforeach ?>
- </table>
+ <?= $this->render('subtask/table', array('subtasks' => $subtasks, 'task' => $task, 'editable' => $editable)) ?>
- <?php if (! isset($not_editable)): ?>
+ <?php if ($editable && $this->user->hasProjectAccess('subtask', 'save', $task['project_id'])): ?>
<form method="post" action="<?= $this->url->href('subtask', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('task_id', array('task_id' => $task['id'])) ?>
@@ -99,4 +18,3 @@
<?php endif ?>
</div>
-<?php endif ?>
diff --git a/app/Template/subtask/table.php b/app/Template/subtask/table.php
new file mode 100644
index 00000000..13d2c7cc
--- /dev/null
+++ b/app/Template/subtask/table.php
@@ -0,0 +1,71 @@
+<?php if (! empty($subtasks)): ?>
+ <table
+ class="subtasks-table table-stripped"
+ data-save-position-url="<?= $this->url->href('Subtask', 'movePosition', array('project_id' => $task['project_id'], 'task_id' => $task['id'])) ?>"
+ >
+ <thead>
+ <tr>
+ <th class="column-40"><?= t('Title') ?></th>
+ <th><?= t('Assignee') ?></th>
+ <th><?= t('Time tracking') ?></th>
+ <?php if ($editable): ?>
+ <th class="column-5"></th>
+ <?php endif ?>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($subtasks as $subtask): ?>
+ <tr data-subtask-id="<?= $subtask['id'] ?>">
+ <td>
+ <?php if ($editable): ?>
+ <i class="fa fa-arrows-alt draggable-row-handle" title="<?= t('Change subtask position') ?>"></i>
+ <?= $this->subtask->toggleStatus($subtask, $task['project_id'], true) ?>
+ <?php else: ?>
+ <?= $this->subtask->getTitle($subtask) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?php if (! empty($subtask['username'])): ?>
+ <?= $this->e($subtask['name'] ?: $subtask['username']) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <ul class="no-bullet">
+ <li>
+ <?php if (! empty($subtask['time_spent'])): ?>
+ <strong><?= $this->e($subtask['time_spent']).'h' ?></strong> <?= t('spent') ?>
+ <?php endif ?>
+
+ <?php if (! empty($subtask['time_estimated'])): ?>
+ <strong><?= $this->e($subtask['time_estimated']).'h' ?></strong> <?= t('estimated') ?>
+ <?php endif ?>
+ </li>
+ <?php if ($editable && $subtask['user_id'] == $this->user->getId()): ?>
+ <li>
+ <?php if ($subtask['is_timer_started']): ?>
+ <i class="fa fa-pause"></i>
+ <?= $this->url->link(t('Stop timer'), 'SubtaskStatus', 'timer', array('timer' => 'stop', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id']), false, 'subtask-toggle-timer') ?>
+ (<?= $this->dt->age($subtask['timer_start_date']) ?>)
+ <?php else: ?>
+ <i class="fa fa-play-circle-o"></i>
+ <?= $this->url->link(t('Start timer'), 'SubtaskStatus', 'timer', array('timer' => 'start', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id']), false, 'subtask-toggle-timer') ?>
+ <?php endif ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </td>
+ <?php if ($editable): ?>
+ <td>
+ <?= $this->render('subtask/menu', array(
+ 'task' => $task,
+ 'subtask' => $subtask,
+ )) ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ </tbody>
+ </table>
+<?php else: ?>
+ <p class="alert"><?= t('There is no subtask at the moment.') ?></p>
+<?php endif ?>
diff --git a/app/Template/subtask/restriction_change_status.php b/app/Template/subtask_restriction/popover.php
index 88e91d82..e80d6b6d 100644
--- a/app/Template/subtask/restriction_change_status.php
+++ b/app/Template/subtask_restriction/popover.php
@@ -1,18 +1,16 @@
<div class="page-header">
<h2><?= t('You already have one subtask in progress') ?></h2>
</div>
-
- <form action="<?= $this->url->href('subtask', 'changeRestrictionStatus', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id'])) ?>" method="post">
+<form class="popover-form" action="<?= $this->url->href('SubtaskRestriction', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id'])) ?>" method="post">
<?= $this->form->csrf() ?>
- <?= $this->form->hidden('redirect', array('redirect' => $redirect)) ?>
<p><?= t('Select the new status of the subtask: "%s"', $subtask_inprogress['title']) ?></p>
<?= $this->form->radios('status', $status_list) ?>
<?= $this->form->hidden('id', $subtask_inprogress) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-red"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-red">
<?= t('or') ?>
<a href="#" class="close-popover"><?= t('cancel') ?></a>
</div>
diff --git a/app/Template/swimlane/create.php b/app/Template/swimlane/create.php
new file mode 100644
index 00000000..bb389555
--- /dev/null
+++ b/app/Template/swimlane/create.php
@@ -0,0 +1,37 @@
+<div class="page-header">
+ <h2><?= t('Add a new swimlane') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('swimlane', 'save', array('project_id' => $project['id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('project_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
+
+ <?= $this->form->label(t('Description'), 'description') ?>
+
+ <div class="form-tabs">
+ <div class="write-area">
+ <?= $this->form->textarea('description', $values, $errors) ?>
+ </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>
+ </li>
+ <li class="form-tab">
+ <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
+ </li>
+ </ul>
+ </div>
+ <div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'Swimlane', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+</form>
diff --git a/app/Template/swimlane/edit.php b/app/Template/swimlane/edit.php
index dfc5cf0b..31d819d4 100644
--- a/app/Template/swimlane/edit.php
+++ b/app/Template/swimlane/edit.php
@@ -2,7 +2,7 @@
<h2><?= t('Swimlane modification for the project "%s"', $project['name']) ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('swimlane', 'update', array('project_id' => $project['id'], 'swimlane_id' => $values['id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('swimlane', 'update', array('project_id' => $project['id'], 'swimlane_id' => $values['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -34,8 +34,8 @@
<div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></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">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'swimlane', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'swimlane', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/swimlane/edit_default.php b/app/Template/swimlane/edit_default.php
new file mode 100644
index 00000000..df25ec12
--- /dev/null
+++ b/app/Template/swimlane/edit_default.php
@@ -0,0 +1,18 @@
+<div class="page-header">
+ <h2><?= t('Change default swimlane') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('swimlane', 'updateDefault', array('project_id' => $project['id'])) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'default_swimlane') ?>
+ <?= $this->form->text('default_swimlane', $values, $errors, array('required', 'maxlength="50"')) ?>
+
+ <?= $this->form->checkbox('show_default_swimlane', t('Show default swimlane'), 1, $values['show_default_swimlane'] == 1) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'Swimlane', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+</form>
diff --git a/app/Template/swimlane/index.php b/app/Template/swimlane/index.php
index 9502cffd..fad35306 100644
--- a/app/Template/swimlane/index.php
+++ b/app/Template/swimlane/index.php
@@ -1,71 +1,28 @@
<div class="page-header">
- <h2><?= t('Change default swimlane') ?></h2>
+ <h2><?= t('Swimlanes') ?></h2>
+ <ul>
+ <li>
+ <i class="fa fa-plus fa-fw"></i>
+ <?= $this->url->link(t('Add a new swimlane'), 'Swimlane', 'create', array('project_id' => $project['id']), false, 'popover') ?>
+ </li>
+ </ul>
</div>
-<form method="post" action="<?= $this->url->href('swimlane', 'change', array('project_id' => $project['id'])) ?>" autocomplete="off">
- <?= $this->form->csrf() ?>
- <?= $this->form->hidden('id', $default_swimlane) ?>
-
- <?= $this->form->label(t('Rename'), 'default_swimlane') ?>
- <?= $this->form->text('default_swimlane', $default_swimlane, array(), array('required', 'maxlength="50"')) ?><br/>
-
- <?php if (! empty($active_swimlanes) || $default_swimlane['show_default_swimlane'] == 0): ?>
- <?= $this->form->checkbox('show_default_swimlane', t('Show default swimlane'), 1, $default_swimlane['show_default_swimlane'] == 1) ?>
- <?php else: ?>
- <?= $this->form->hidden('show_default_swimlane', $default_swimlane) ?>
- <?php endif ?>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- </div>
-</form>
-
-<?php if (! empty($active_swimlanes)): ?>
-<div class="page-header">
- <h2><?= t('Active swimlanes') ?></h2>
-</div>
-<?= $this->render('swimlane/table', array('swimlanes' => $active_swimlanes, 'project' => $project)) ?>
+<?php if (! empty($active_swimlanes) || $default_swimlane['show_default_swimlane'] == 1): ?>
+<h3><?= t('Active swimlanes') ?></h3>
+ <?= $this->render('swimlane/table', array(
+ 'swimlanes' => $active_swimlanes,
+ 'project' => $project,
+ 'default_swimlane' => $default_swimlane['show_default_swimlane'] == 1 ? $default_swimlane : array()
+ )) ?>
<?php endif ?>
-<?php if (! empty($inactive_swimlanes)): ?>
-<div class="page-header">
- <h2><?= t('Inactive swimlanes') ?></h2>
-</div>
-<?= $this->render('swimlane/table', array('swimlanes' => $inactive_swimlanes, 'project' => $project, 'hide_position' => true)) ?>
+<?php if (! empty($inactive_swimlanes) || $default_swimlane['show_default_swimlane'] == 0): ?>
+ <h3><?= t('Inactive swimlanes') ?></h3>
+ <?= $this->render('swimlane/table', array(
+ 'swimlanes' => $inactive_swimlanes,
+ 'project' => $project,
+ 'default_swimlane' => $default_swimlane['show_default_swimlane'] == 0 ? $default_swimlane : array(),
+ 'disable_handler' => true
+ )) ?>
<?php endif ?>
-
-<div class="page-header">
- <h2><?= t('Add a new swimlane') ?></h2>
-</div>
-<form method="post" action="<?= $this->url->href('swimlane', 'save', array('project_id' => $project['id'])) ?>" autocomplete="off">
-
- <?= $this->form->csrf() ?>
- <?= $this->form->hidden('project_id', $values) ?>
-
- <?= $this->form->label(t('Name'), 'name') ?>
- <?= $this->form->text('name', $values, $errors, array('required', 'maxlength="50"')) ?>
-
- <?= $this->form->label(t('Description'), 'description') ?>
-
- <div class="form-tabs">
- <div class="write-area">
- <?= $this->form->textarea('description', $values, $errors) ?>
- </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>
- </li>
- <li class="form-tab">
- <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
- </li>
- </ul>
- </div>
- <div class="form-help"><?= $this->url->doc(t('Write your text in Markdown'), 'syntax-guide') ?></div>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- </div>
-</form>
diff --git a/app/Template/swimlane/remove.php b/app/Template/swimlane/remove.php
index 1d7c2b7a..9be39ff8 100644
--- a/app/Template/swimlane/remove.php
+++ b/app/Template/swimlane/remove.php
@@ -11,7 +11,7 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'swimlane', 'remove', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'swimlane', 'index', array('project_id' => $project['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'swimlane', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
</div>
</div>
</section> \ No newline at end of file
diff --git a/app/Template/swimlane/table.php b/app/Template/swimlane/table.php
index b708e633..1e6a86bc 100644
--- a/app/Template/swimlane/table.php
+++ b/app/Template/swimlane/table.php
@@ -1,44 +1,76 @@
-<table>
- <tr>
- <?php if (! isset($hide_position)): ?>
- <th><?= t('Position') ?></th>
- <?php endif ?>
- <th class="column-60"><?= t('Name') ?></th>
- <th class="column-35"><?= t('Actions') ?></th>
- </tr>
- <?php foreach ($swimlanes as $swimlane): ?>
- <tr>
- <?php if (! isset($hide_position)): ?>
- <td>#<?= $swimlane['position'] ?></td>
- <?php endif ?>
- <td><?= $this->e($swimlane['name']) ?></td>
- <td>
- <ul>
- <?php if ($swimlane['position'] != 0 && $swimlane['position'] != 1): ?>
+<table
+ class="swimlanes-table table-stripped"
+ data-save-position-url="<?= $this->url->href('Swimlane', 'move', array('project_id' => $project['id'])) ?>">
+ <thead>
+ <tr>
+ <th><?= t('Name') ?></th>
+ <th class="column-8"><?= t('Actions') ?></th>
+ </tr>
+
+ <?php if (! empty($default_swimlane)): ?>
+ <tr>
+ <td>
+ <?= $this->e($default_swimlane['default_swimlane']) ?>
+ <?php if ($default_swimlane['default_swimlane'] !== t('Default swimlane')): ?>
+ &nbsp;(<?= t('Default swimlane') ?>)
+ <?php endif ?>
+ </td>
+ <td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
<li>
- <?= $this->url->link(t('Move Up'), 'swimlane', 'moveup', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true) ?>
+ <?= $this->url->link(t('Edit'), 'Swimlane', 'editDefault', array('project_id' => $project['id']), false, 'popover') ?>
</li>
- <?php endif ?>
- <?php if ($swimlane['position'] != 0 && $swimlane['position'] != count($swimlanes)): ?>
<li>
- <?= $this->url->link(t('Move Down'), 'swimlane', 'movedown', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true) ?>
+ <?php if ($default_swimlane['show_default_swimlane'] == 1): ?>
+ <?= $this->url->link(t('Disable'), 'Swimlane', 'disableDefault', array('project_id' => $project['id']), true) ?>
+ <?php else: ?>
+ <?= $this->url->link(t('Enable'), 'Swimlane', 'enableDefault', array('project_id' => $project['id']), true) ?>
+ <?php endif ?>
</li>
+ </ul>
+ </td>
+ </tr>
+ <?php endif ?>
+ </thead>
+ <tbody>
+ <?php foreach ($swimlanes as $swimlane): ?>
+ <tr data-swimlane-id="<?= $swimlane['id'] ?>">
+ <td>
+ <?php if (! isset($disable_handler)): ?>
+ <i class="fa fa-arrows-alt draggable-row-handle" title="<?= t('Change column position') ?>"></i>
+ <?php endif ?>
+
+ <?= $this->e($swimlane['name']) ?>
+
+ <?php if (! empty($swimlane['description'])): ?>
+ <span class="tooltip" title='<?= $this->e($this->text->markdown($swimlane['description'])) ?>'>
+ <i class="fa fa-info-circle"></i>
+ </span>
<?php endif ?>
- <li>
- <?= $this->url->link(t('Edit'), 'swimlane', 'edit', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id'])) ?>
- </li>
- <li>
- <?php if ($swimlane['is_active']): ?>
- <?= $this->url->link(t('Disable'), 'swimlane', 'disable', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true) ?>
- <?php else: ?>
- <?= $this->url->link(t('Enable'), 'swimlane', 'enable', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true) ?>
- <?php endif ?>
- </li>
- <li>
- <?= $this->url->link(t('Remove'), 'swimlane', 'confirm', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id'])) ?>
- </li>
- </ul>
- </td>
- </tr>
- <?php endforeach ?>
-</table> \ No newline at end of file
+ </td>
+ <td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <?= $this->url->link(t('Edit'), 'swimlane', 'edit', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), false, 'popover') ?>
+ </li>
+ <li>
+ <?php if ($swimlane['is_active']): ?>
+ <?= $this->url->link(t('Disable'), 'swimlane', 'disable', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true) ?>
+ <?php else: ?>
+ <?= $this->url->link(t('Enable'), 'swimlane', 'enable', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), true) ?>
+ <?php endif ?>
+ </li>
+ <li>
+ <?= $this->url->link(t('Remove'), 'swimlane', 'confirm', array('project_id' => $project['id'], 'swimlane_id' => $swimlane['id']), false, 'popover') ?>
+ </li>
+ </ul>
+ </div>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </tbody>
+</table>
diff --git a/app/Template/task/changes.php b/app/Template/task/changes.php
index f288a8f4..db844857 100644
--- a/app/Template/task/changes.php
+++ b/app/Template/task/changes.php
@@ -31,7 +31,7 @@
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>';
+ echo '<li>'.t('New due date: ').$this->dt->date($task['date_due']).'</li>';
}
break;
case 'description':
@@ -56,7 +56,7 @@
break;
case 'date_started':
if ($value != 0) {
- echo '<li>'.dt('Start date changed: %B %e, %Y', $task['date_started']).'</li>';
+ echo '<li>'.t('Start date changed: ').$this->dt->datetime($task['date_started']).'</li>';
}
break;
default:
diff --git a/app/Template/task/comments.php b/app/Template/task/comments.php
index 070de320..c22e39ec 100644
--- a/app/Template/task/comments.php
+++ b/app/Template/task/comments.php
@@ -15,12 +15,12 @@
'comment' => $comment,
'task' => $task,
'project' => $project,
- 'not_editable' => isset($not_editable) && $not_editable,
+ 'editable' => $editable,
'is_public' => isset($is_public) && $is_public,
)) ?>
<?php endforeach ?>
- <?php if (! isset($not_editable)): ?>
+ <?php if ($editable): ?>
<?= $this->render('comment/create', array(
'skip_cancel' => true,
'values' => array(
@@ -28,7 +28,7 @@
'task_id' => $task['id'],
),
'errors' => array(),
- 'task' => $task
+ 'task' => $task,
)) ?>
<?php endif ?>
</div>
diff --git a/app/Template/task/details.php b/app/Template/task/details.php
index 9cd10dda..5c2e3cff 100644
--- a/app/Template/task/details.php
+++ b/app/Template/task/details.php
@@ -1,101 +1,130 @@
-<div class="color-<?= $task['color_id'] ?> task-show-details">
- <h2><?= $this->e('#'.$task['id'].' '.$task['title']) ?></h2>
- <?php if ($task['score']): ?>
- <span class="task-score"><?= $this->e($task['score']) ?></span>
- <?php endif ?>
- <ul>
- <?php if ($task['reference']): ?>
- <li>
- <strong><?= t('Reference: %s', $task['reference']) ?></strong>
- </li>
- <?php endif ?>
- <?php if (! empty($task['swimlane_name'])): ?>
- <li>
- <?= t('Swimlane: %s', $task['swimlane_name']) ?>
- </li>
- <?php endif ?>
- <li>
- <?= dt('Created on %B %e, %Y at %k:%M %p', $task['date_creation']) ?>
- </li>
- <?php if ($task['date_modification']): ?>
- <li>
- <?= dt('Last modified on %B %e, %Y at %k:%M %p', $task['date_modification']) ?>
- </li>
- <?php endif ?>
- <?php if ($task['date_completed']): ?>
- <li>
- <?= dt('Completed on %B %e, %Y at %k:%M %p', $task['date_completed']) ?>
- </li>
- <?php endif ?>
- <?php if ($task['date_started']): ?>
- <li>
- <?= dt('Started on %B %e, %Y', $task['date_started']) ?>
- </li>
- <?php endif ?>
- <?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['time_estimated']): ?>
- <li>
- <?= t('Estimated time: %s hours', $task['time_estimated']) ?>
- </li>
- <?php endif ?>
- <?php if ($task['time_spent']): ?>
- <li>
- <?= t('Time spent: %s hours', $task['time_spent']) ?>
- </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>
- (<?= $this->e($task['project_name']) ?>)
- <?= dt('since %B %e, %Y at %k:%M %p', $task['date_moved']) ?>
- </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 ?>
- <li>
- <?php if ($task['is_active'] == 1): ?>
- <?= t('Status is open') ?>
- <?php else: ?>
- <?= t('Status is closed') ?>
- <?php endif ?>
- </li>
- <?php if ($project['is_public']): ?>
- <li>
- <?= $this->url->link(t('Public link'), 'task', 'readonly', array('task_id' => $task['id'], 'token' => $project['token']), false, '', '', true) ?>
- </li>
- <?php endif ?>
-
- <?php if (! isset($not_editable) && $task['recurrence_status'] != \Kanboard\Model\Task::RECURRING_STATUS_NONE): ?>
- <li>
- <strong><?= t('Recurring information') ?></strong>
- <?= $this->render('task/recurring_info', array(
- 'task' => $task,
- 'recurrence_trigger_list' => $recurrence_trigger_list,
- 'recurrence_timeframe_list' => $recurrence_timeframe_list,
- 'recurrence_basedate_list' => $recurrence_basedate_list,
- )) ?>
- </li>
- <?php endif ?>
- </ul>
-</div>
+<section id="task-summary">
+ <h2><?= $this->e($task['title']) ?></h2>
+ <div class="task-summary-container color-<?= $task['color_id'] ?>">
+ <div class="task-summary-column">
+ <ul class="no-bullet">
+ <li>
+ <strong><?= t('Status:') ?></strong>
+ <span>
+ <?php if ($task['is_active'] == 1): ?>
+ <?= t('open') ?>
+ <?php else: ?>
+ <?= t('closed') ?>
+ <?php endif ?>
+ </span>
+ </li>
+ <li>
+ <strong><?= t('Priority:') ?></strong> <span><?= $task['priority'] ?></span>
+ </li>
+ <?php if (! empty($task['reference'])): ?>
+ <li>
+ <strong><?= t('Reference:') ?></strong> <span><?= $this->e($task['reference']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if (! empty($task['score'])): ?>
+ <li>
+ <strong><?= t('Complexity:') ?></strong> <span><?= $this->e($task['score']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if ($project['is_public']): ?>
+ <li class="smaller">
+ <i class="fa fa-external-link fa-fw"></i>
+ <?= $this->url->link(t('Public link'), 'task', 'readonly', array('task_id' => $task['id'], 'token' => $project['token']), false, '', '', true) ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ <div class="task-summary-column">
+ <ul class="no-bullet">
+ <?php if (! empty($task['category_name'])): ?>
+ <li>
+ <strong><?= t('Category:') ?></strong>
+ <span><?= $this->e($task['category_name']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if (! empty($task['swimlane_name'])): ?>
+ <li>
+ <strong><?= t('Swimlane:') ?></strong>
+ <span><?= $this->e($task['swimlane_name']) ?></span>
+ </li>
+ <?php endif ?>
+ <li>
+ <strong><?= t('Column:') ?></strong>
+ <span><?= $this->e($task['column_title']) ?></span>
+ </li>
+ <li>
+ <strong><?= t('Position:') ?></strong>
+ <span><?= $task['position'] ?></span>
+ </li>
+ </ul>
+ </div>
+ <div class="task-summary-column">
+ <ul class="no-bullet">
+ <li>
+ <strong><?= t('Assignee:') ?></strong>
+ <span>
+ <?php if ($task['assignee_username']): ?>
+ <?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>
+ <?php else: ?>
+ <?= t('not assigned') ?>
+ <?php endif ?>
+ </span>
+ </li>
+ <?php if ($task['creator_username']): ?>
+ <li>
+ <strong><?= t('Creator:') ?></strong>
+ <span><?= $this->e($task['creator_name'] ?: $task['creator_username']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if ($task['date_due']): ?>
+ <li>
+ <strong><?= t('Due date:') ?></strong>
+ <span><?= $this->dt->date($task['date_due']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if ($task['time_estimated']): ?>
+ <li>
+ <strong><?= t('Time estimated:') ?></strong>
+ <span><?= t('%s hours', $task['time_estimated']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if ($task['time_spent']): ?>
+ <li>
+ <strong><?= t('Time spent:') ?></strong>
+ <span><?= t('%s hours', $task['time_spent']) ?></span>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ <div class="task-summary-column">
+ <ul class="no-bullet">
+ <li>
+ <strong><?= t('Created:') ?></strong>
+ <span><?= $this->dt->datetime($task['date_creation']) ?></span>
+ </li>
+ <li>
+ <strong><?= t('Modified:') ?></strong>
+ <span><?= $this->dt->datetime($task['date_modification']) ?></span>
+ </li>
+ <?php if ($task['date_completed']): ?>
+ <li>
+ <strong><?= t('Completed:') ?></strong>
+ <span><?= $this->dt->datetime($task['date_completed']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if ($task['date_started']): ?>
+ <li>
+ <strong><?= t('Started:') ?></strong>
+ <span><?= $this->dt->datetime($task['date_started']) ?></span>
+ </li>
+ <?php endif ?>
+ <?php if ($task['date_moved']): ?>
+ <li>
+ <strong><?= t('Moved:') ?></strong>
+ <span><?= $this->dt->datetime($task['date_moved']) ?></span>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ </div>
+</section>
diff --git a/app/Template/task/dropdown.php b/app/Template/task/dropdown.php
new file mode 100644
index 00000000..3300ccf0
--- /dev/null
+++ b/app/Template/task/dropdown.php
@@ -0,0 +1,60 @@
+<div class="dropdown">
+ <a href="#" class="dropdown-menu">#<?= $task['id'] ?></a>
+ <ul>
+ <?php if (isset($task['date_started']) && empty($task['date_started'])): ?>
+ <li>
+ <i class="fa fa-play fa-fw"></i>
+ <?= $this->url->link(t('Set automatically the start date'), 'taskmodification', 'start', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </li>
+ <?php endif ?>
+ <li>
+ <i class="fa fa-pencil-square-o fa-fw"></i>
+ <?= $this->url->link(t('Edit the task'), 'taskmodification', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-align-left fa-fw"></i>
+ <?= $this->url->link(t('Edit the description'), 'taskmodification', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-plus fa-fw"></i>
+ <?= $this->url->link(t('Add a sub-task'), 'subtask', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-code-fork fa-fw"></i>
+ <?= $this->url->link(t('Add internal link'), 'tasklink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-external-link fa-fw"></i>
+ <?= $this->url->link(t('Add external link'), 'TaskExternalLink', 'find', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-comment-o fa-fw"></i>
+ <?= $this->url->link(t('Add a comment'), 'comment', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-files-o fa-fw"></i>
+ <?= $this->url->link(t('Duplicate'), 'taskduplication', 'duplicate', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-clipboard fa-fw"></i>
+ <?= $this->url->link(t('Duplicate to another project'), 'taskduplication', 'copy', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-clone fa-fw"></i>
+ <?= $this->url->link(t('Move to another project'), 'taskduplication', 'move', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <?php if (isset($task['is_active'])): ?>
+ <li>
+ <?php if ($task['is_active'] == 1): ?>
+ <i class="fa fa-times fa-fw"></i>
+ <?= $this->url->link(t('Close this task'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ <?php else: ?>
+ <i class="fa fa-check-square-o fa-fw"></i>
+ <?= $this->url->link(t('Open this task'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ <?php endif ?>
+ </li>
+ <?php endif ?>
+
+ <?= $this->hook->render('template:task:dropdown') ?>
+ </ul>
+</div>
diff --git a/app/Template/task/layout.php b/app/Template/task/layout.php
index 6b6e827a..9cbbfec9 100644
--- a/app/Template/task/layout.php
+++ b/app/Template/task/layout.php
@@ -2,6 +2,9 @@
<div class="page-header">
<ul>
<li>
+ <?= $this->render('task/menu', array('task' => $task)) ?>
+ </li>
+ <li>
<i class="fa fa-th fa-fw"></i>
<?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $task['project_id']), false, '', '', false, $task['swimlane_id'] != 0 ? 'swimlane-'.$task['swimlane_id'] : '') ?>
</li>
@@ -9,7 +12,7 @@
<i class="fa fa-calendar fa-fw"></i>
<?= $this->url->link(t('Back to the calendar'), 'calendar', 'show', array('project_id' => $task['project_id'])) ?>
</li>
- <?php if ($this->user->isProjectManagementAllowed($task['project_id'])): ?>
+ <?php if ($this->user->hasProjectAccess('ProjectEdit', 'edit', $task['project_id'])): ?>
<li>
<i class="fa fa-cog fa-fw"></i>
<?= $this->url->link(t('Project settings'), 'project', 'show', array('project_id' => $task['project_id'])) ?>
@@ -17,12 +20,12 @@
<?php endif ?>
</ul>
</div>
- <section class="sidebar-container" id="task-section">
+ <section class="sidebar-container">
- <?= $this->render('task/sidebar', array('task' => $task)) ?>
+ <?= $this->render($sidebar_template, array('task' => $task)) ?>
<div class="sidebar-content">
- <?= $task_content_for_layout ?>
+ <?= $content_for_sublayout ?>
</div>
</section>
</section> \ No newline at end of file
diff --git a/app/Template/task/menu.php b/app/Template/task/menu.php
new file mode 100644
index 00000000..cddd930a
--- /dev/null
+++ b/app/Template/task/menu.php
@@ -0,0 +1,78 @@
+<?php if ($this->user->hasProjectAccess('taskmodification', 'edit', $task['project_id'])): ?>
+<div class="dropdown">
+ <i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Actions') ?></a>
+ <ul>
+ <?php if (empty($task['date_started'])): ?>
+ <li>
+ <i class="fa fa-play fa-fw"></i>
+ <?= $this->url->link(t('Set automatically the start date'), 'taskmodification', 'start', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </li>
+ <?php endif ?>
+ <li>
+ <i class="fa fa-pencil-square-o fa-fw"></i>
+ <?= $this->url->link(t('Edit the task'), 'taskmodification', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-align-left fa-fw"></i>
+ <?= $this->url->link(t('Edit the description'), 'taskmodification', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-refresh fa-rotate-90 fa-fw"></i>
+ <?= $this->url->link(t('Edit recurrence'), 'TaskRecurrence', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-plus fa-fw"></i>
+ <?= $this->url->link(t('Add a sub-task'), 'subtask', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-code-fork fa-fw"></i>
+ <?= $this->url->link(t('Add internal link'), 'tasklink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-external-link fa-fw"></i>
+ <?= $this->url->link(t('Add external link'), 'TaskExternalLink', 'find', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-comment-o fa-fw"></i>
+ <?= $this->url->link(t('Add a comment'), 'comment', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-file fa-fw"></i>
+ <?= $this->url->link(t('Attach a document'), 'TaskFile', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-camera fa-fw"></i>
+ <?= $this->url->link(t('Add a screenshot'), 'TaskFile', 'screenshot', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-files-o fa-fw"></i>
+ <?= $this->url->link(t('Duplicate'), 'taskduplication', 'duplicate', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-clipboard fa-fw"></i>
+ <?= $this->url->link(t('Duplicate to another project'), 'taskduplication', 'copy', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <i class="fa fa-clone fa-fw"></i>
+ <?= $this->url->link(t('Move to another project'), 'taskduplication', 'move', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <li>
+ <?php if ($task['is_active'] == 1): ?>
+ <i class="fa fa-times fa-fw"></i>
+ <?= $this->url->link(t('Close this task'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ <?php else: ?>
+ <i class="fa fa-check-square-o fa-fw"></i>
+ <?= $this->url->link(t('Open this task'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ <?php endif ?>
+ </li>
+ <?php if ($this->task->canRemove($task)): ?>
+ <li>
+ <i class="fa fa-trash-o fa-fw"></i>
+ <?= $this->url->link(t('Remove'), 'task', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+
+ <?= $this->hook->render('template:task:menu') ?>
+ </ul>
+</div>
+<?php endif ?>
diff --git a/app/Template/task/public.php b/app/Template/task/public.php
index e3105488..7edf097c 100644
--- a/app/Template/task/public.php
+++ b/app/Template/task/public.php
@@ -1,33 +1,34 @@
<section id="main" class="public-task">
- <?= $this->render('task/details', array('task' => $task, 'project' => $project, 'not_editable' => true)) ?>
+ <?= $this->render('task/details', array('task' => $task, 'project' => $project, 'editable' => false)) ?>
<p class="pull-right"><?= $this->url->link(t('Back to the board'), 'board', 'readonly', array('token' => $project['token'])) ?></p>
<?= $this->render('task/description', array(
'task' => $task,
'project' => $project,
- 'is_public' => true
+ 'is_public' => true,
)) ?>
<?= $this->render('tasklink/show', array(
'task' => $task,
'links' => $links,
'project' => $project,
- 'not_editable' => true
+ 'editable' => false,
+ 'is_public' => true,
)) ?>
<?= $this->render('subtask/show', array(
'task' => $task,
'subtasks' => $subtasks,
- 'not_editable' => true
+ 'editable' => false
)) ?>
<?= $this->render('task/comments', array(
'task' => $task,
'comments' => $comments,
'project' => $project,
- 'not_editable' => true,
+ 'editable' => false,
'is_public' => true,
)) ?>
diff --git a/app/Template/task/remove.php b/app/Template/task/remove.php
index 2f6edc22..e0d655fe 100644
--- a/app/Template/task/remove.php
+++ b/app/Template/task/remove.php
@@ -10,6 +10,6 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'task', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'confirmation' => 'yes'), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/task/show.php b/app/Template/task/show.php
index 68d63c58..0c77f576 100644
--- a/app/Template/task/show.php
+++ b/app/Template/task/show.php
@@ -1,15 +1,38 @@
<?= $this->render('task/details', array(
'task' => $task,
'project' => $project,
- 'recurrence_trigger_list' => $this->task->recurrenceTriggers(),
- 'recurrence_timeframe_list' => $this->task->recurrenceTimeframes(),
- 'recurrence_basedate_list' => $this->task->recurrenceBasedates(),
+ 'editable' => $this->user->hasProjectAccess('taskmodification', 'edit', $project['id']),
)) ?>
-<?= $this->render('task_modification/edit_time', array('task' => $task, 'values' => $values, 'date_format' => $date_format, 'date_formats' => $date_formats)) ?>
<?= $this->render('task/description', array('task' => $task)) ?>
-<?= $this->render('tasklink/show', array('task' => $task, 'links' => $links, 'link_label_list' => $link_label_list)) ?>
-<?= $this->render('subtask/show', array('task' => $task, 'subtasks' => $subtasks, 'project' => $project, 'users_list' => isset($users_list) ? $users_list : array())) ?>
+
+<?= $this->render('subtask/show', array(
+ 'task' => $task,
+ 'subtasks' => $subtasks,
+ 'project' => $project,
+ 'users_list' => isset($users_list) ? $users_list : array(),
+ 'editable' => true,
+)) ?>
+
+<?= $this->render('tasklink/show', array(
+ 'task' => $task,
+ 'links' => $links,
+ 'link_label_list' => $link_label_list,
+ 'editable' => true,
+ 'is_public' => false,
+)) ?>
+
<?= $this->render('task/time_tracking_summary', array('task' => $task)) ?>
-<?= $this->render('file/show', array('task' => $task, 'files' => $files, 'images' => $images)) ?>
-<?= $this->render('task/comments', array('task' => $task, 'comments' => $comments, 'project' => $project)) ?>
+
+<?= $this->render('task_file/show', array(
+ 'task' => $task,
+ 'files' => $files,
+ 'images' => $images
+)) ?>
+
+<?= $this->render('task/comments', array(
+ 'task' => $task,
+ 'comments' => $comments,
+ 'project' => $project,
+ 'editable' => $this->user->hasProjectAccess('comment', 'edit', $project['id']),
+)) ?>
diff --git a/app/Template/task/sidebar.php b/app/Template/task/sidebar.php
index 9ee1e7df..951c5095 100644
--- a/app/Template/task/sidebar.php
+++ b/app/Template/task/sidebar.php
@@ -1,76 +1,33 @@
<div class="sidebar">
- <h2><?= t('Information') ?></h2>
+ <h2><?= t('Task #%d', $task['id']) ?></h2>
<ul>
- <li <?= $this->app->getRouterController() === 'task' && $this->app->getRouterAction() === 'show' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('task', 'show') ?>>
<?= $this->url->link(t('Summary'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'activity' && $this->app->getRouterAction() === 'task' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('activity', 'task') ?>>
<?= $this->url->link(t('Activity stream'), 'activity', 'task', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'task' && $this->app->getRouterAction() === 'transitions' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('task', 'transitions') ?>>
<?= $this->url->link(t('Transitions'), 'task', 'transitions', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'task' && $this->app->getRouterAction() === 'analytics' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('task', 'analytics') ?>>
<?= $this->url->link(t('Analytics'), 'task', 'analytics', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<?php if ($task['time_estimated'] > 0 || $task['time_spent'] > 0): ?>
- <li <?= $this->app->getRouterController() === 'task' && $this->app->getRouterAction() === 'timetracking' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('task', 'timetracking') ?>>
<?= $this->url->link(t('Time tracking'), 'task', 'timetracking', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<?php endif ?>
-
- <?= $this->hook->render('template:task:sidebar:information') ?>
- </ul>
- <h2><?= t('Actions') ?></h2>
- <ul>
- <li <?= $this->app->getRouterController() === 'taskmodification' && $this->app->getRouterAction() === 'edit' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Edit the task'), 'taskmodification', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'taskmodification' && $this->app->getRouterAction() === 'description' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Edit the description'), 'taskmodification', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'taskmodification' && $this->app->getRouterAction() === 'recurrence' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Edit recurrence'), 'taskmodification', 'recurrence', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'subtask' && $this->app->getRouterAction() === 'create' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Add a sub-task'), 'subtask', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'tasklink' && $this->app->getRouterAction() === 'create' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Add a link'), 'tasklink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <li <?= $this->app->checkMenuSelection('subtask', 'show') ?>>
+ <?= $this->url->link(t('Sub-tasks'), 'subtask', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'comment' && $this->app->getRouterAction() === 'create' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Add a comment'), 'comment', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <li <?= $this->app->checkMenuSelection('tasklink', 'show') ?>>
+ <?= $this->url->link(t('Internal links'), 'tasklink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'file' && $this->app->getRouterAction() === 'create' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Attach a document'), 'file', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <li <?= $this->app->checkMenuSelection('TaskExternalLink', 'show') ?>>
+ <?= $this->url->link(t('External links'), 'TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'file' && $this->app->getRouterAction() === 'screenshot' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Add a screenshot'), 'file', 'screenshot', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'taskduplication' && $this->app->getRouterAction() === 'duplicate' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Duplicate'), 'taskduplication', 'duplicate', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'taskduplication' && $this->app->getRouterAction() === 'copy' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Duplicate to another project'), 'taskduplication', 'copy', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'taskduplication' && $this->app->getRouterAction() === 'move' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Move to another project'), 'taskduplication', 'move', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <li <?= $this->app->getRouterController() === 'taskstatus' ? 'class="active"' : '' ?>>
- <?php if ($task['is_active'] == 1): ?>
- <?= $this->url->link(t('Close this task'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- <?php else: ?>
- <?= $this->url->link(t('Open this task'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- <?php endif ?>
- </li>
- <?php if ($this->task->canRemove($task)): ?>
- <li <?= $this->app->getRouterController() === 'task' && $this->app->getRouterAction() === 'remove' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Remove'), 'task', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- </li>
- <?php endif ?>
- <?= $this->hook->render('template:task:sidebar:actions') ?>
+ <?= $this->hook->render('template:task:sidebar', array('task' => $task)) ?>
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
</div>
diff --git a/app/Template/task/time_tracking_details.php b/app/Template/task/time_tracking_details.php
index faa07cb8..147c5109 100644
--- a/app/Template/task/time_tracking_details.php
+++ b/app/Template/task/time_tracking_details.php
@@ -10,14 +10,14 @@
<th><?= $subtask_paginator->order(t('Subtask'), 'subtask_title') ?></th>
<th class="column-20"><?= $subtask_paginator->order(t('Start'), 'start') ?></th>
<th class="column-20"><?= $subtask_paginator->order(t('End'), 'end') ?></th>
- <th class="column-10"><?= $subtask_paginator->order(t('Time spent'), 'time_spent') ?></th>
+ <th class="column-10"><?= $subtask_paginator->order(t('Time spent'), \Kanboard\Model\SubtaskTimeTracking::TABLE.'.time_spent') ?></th>
</tr>
<?php foreach ($subtask_paginator->getCollection() as $record): ?>
<tr>
<td><?= $this->url->link($this->e($record['user_fullname'] ?: $record['username']), 'user', 'show', array('user_id' => $record['user_id'])) ?></td>
<td><?= t($record['subtask_title']) ?></td>
- <td><?= dt('%B %e, %Y at %k:%M %p', $record['start']) ?></td>
- <td><?= dt('%B %e, %Y at %k:%M %p', $record['end']) ?></td>
+ <td><?= $this->dt->datetime($record['start']) ?></td>
+ <td><?= $this->dt->datetime($record['end']) ?></td>
<td><?= n($record['time_spent']).' '.t('hours') ?></td>
</tr>
<?php endforeach ?>
diff --git a/app/Template/task/transitions.php b/app/Template/task/transitions.php
index 2ca2387f..d79c2fd9 100644
--- a/app/Template/task/transitions.php
+++ b/app/Template/task/transitions.php
@@ -15,7 +15,7 @@
</tr>
<?php foreach ($transitions as $transition): ?>
<tr>
- <td><?= dt('%B %e, %Y at %k:%M %p', $transition['date']) ?></td>
+ <td><?= $this->dt->datetime($transition['date']) ?></td>
<td><?= $this->e($transition['src_column']) ?></td>
<td><?= $this->e($transition['dst_column']) ?></td>
<td><?= $this->url->link($this->e($transition['name'] ?: $transition['username']), 'user', 'show', array('user_id' => $transition['user_id'])) ?></td>
diff --git a/app/Template/task_creation/form.php b/app/Template/task_creation/form.php
index 325ca1c8..84f74c36 100644
--- a/app/Template/task_creation/form.php
+++ b/app/Template/task_creation/form.php
@@ -1,28 +1,29 @@
-<?php if (! $ajax): ?>
-<div class="page-header">
- <ul>
- <li><i class="fa fa-th fa-fw"></i><?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $values['project_id'])) ?></li>
- </ul>
-</div>
-<?php else: ?>
<div class="page-header">
<h2><?= t('New task') ?></h2>
</div>
-<?php endif ?>
-<form id="task-form" method="post" action="<?= $this->url->href('taskcreation', 'save', array('project_id' => $values['project_id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('taskcreation', 'save', array('project_id' => $values['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<div class="form-column">
<?= $this->form->label(t('Title'), 'title') ?>
- <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large') ?><br/>
+ <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large') ?>
<?= $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"')) ?>
+ <?= $this->form->textarea(
+ 'description',
+ $values,
+ $errors,
+ array(
+ 'placeholder="'.t('Leave a description').'"',
+ 'tabindex="2"',
+ 'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $values['project_id'])).'"'
+ )
+ ) ?>
</div>
<div class="preview-area">
<div class="markdown"></div>
@@ -46,34 +47,20 @@
<div class="form-column">
<?= $this->form->hidden('project_id', $values) ?>
-
- <?= $this->form->label(t('Assignee'), 'owner_id') ?>
- <?= $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, 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, array('tabindex="5"')) ?><br/>
- <?php endif ?>
-
- <?= $this->form->label(t('Column'), 'column_id') ?>
- <?= $this->form->select('column_id', $columns_list, $values, $errors, array('tabindex="6"')) ?><br/>
-
- <?= $this->form->label(t('Complexity'), 'score') ?>
- <?= $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, 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).'"', '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>
+ <?= $this->task->selectAssignee($users_list, $values, $errors) ?>
+ <?= $this->task->selectCategory($categories_list, $values, $errors) ?>
+ <?= $this->task->selectSwimlane($swimlanes_list, $values, $errors) ?>
+ <?= $this->task->selectColumn($columns_list, $values, $errors) ?>
+ <?= $this->task->selectPriority($project, $values) ?>
+ <?= $this->task->selectScore($values, $errors) ?>
+ <?= $this->task->selectTimeEstimated($values, $errors) ?>
+ <?= $this->task->selectDueDate($values, $errors) ?>
+
+ <?= $this->hook->render('template:task:form:right-column', array('values'=>$values, 'errors'=>$errors)) ?>
</div>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" tabindex="11"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" tabindex="12"/>
<?= t('or') ?> <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $values['project_id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/task_duplication/copy.php b/app/Template/task_duplication/copy.php
index 415b8610..fe2c599a 100644
--- a/app/Template/task_duplication/copy.php
+++ b/app/Template/task_duplication/copy.php
@@ -6,7 +6,7 @@
<p class="alert"><?= t('There is no destination project available.') ?></p>
<?php else: ?>
- <form method="post" action="<?= $this->url->href('taskduplication', 'copy', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+ <form class="popover-form" method="post" action="<?= $this->url->href('taskduplication', 'copy', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('id', $values) ?>
@@ -39,9 +39,9 @@
<p class="form-help"><?= t('Current assignee: %s', ($task['assignee_name'] ?: $task['assignee_username']) ?: e('not assigned')) ?></p>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/task_duplication/duplicate.php b/app/Template/task_duplication/duplicate.php
index 4b50d9ca..376f6b3b 100644
--- a/app/Template/task_duplication/duplicate.php
+++ b/app/Template/task_duplication/duplicate.php
@@ -10,6 +10,6 @@
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'taskduplication', 'duplicate', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'confirmation' => 'yes'), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/task_duplication/move.php b/app/Template/task_duplication/move.php
index d8d1ba05..8ab81f5b 100644
--- a/app/Template/task_duplication/move.php
+++ b/app/Template/task_duplication/move.php
@@ -6,7 +6,7 @@
<p class="alert"><?= t('There is no destination project available.') ?></p>
<?php else: ?>
- <form method="post" action="<?= $this->url->href('taskduplication', 'move', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+ <form class="popover-form" method="post" action="<?= $this->url->href('taskduplication', 'move', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('id', $values) ?>
@@ -39,9 +39,9 @@
<p class="form-help"><?= t('Current assignee: %s', ($task['assignee_name'] ?: $task['assignee_username']) ?: e('not assigned')) ?></p>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/task_external_link/create.php b/app/Template/task_external_link/create.php
new file mode 100644
index 00000000..b62abdb2
--- /dev/null
+++ b/app/Template/task_external_link/create.php
@@ -0,0 +1,13 @@
+<div class="page-header">
+ <h2><?= t('Add a new external link') ?></h2>
+</div>
+
+<form class="popover-form" action="<?= $this->url->href('TaskExternalLink', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" autocomplete="off">
+ <?= $this->render('task_external_link/form', array('task' => $task, 'dependencies' => $dependencies, 'values' => $values, 'errors' => $errors)) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/task_external_link/edit.php b/app/Template/task_external_link/edit.php
new file mode 100644
index 00000000..8caaaebe
--- /dev/null
+++ b/app/Template/task_external_link/edit.php
@@ -0,0 +1,13 @@
+<div class="page-header">
+ <h2><?= t('Edit external link') ?></h2>
+</div>
+
+<form class="popover-form" action="<?= $this->url->href('TaskExternalLink', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" autocomplete="off">
+ <?= $this->render('task_external_link/form', array('task' => $task, 'dependencies' => $dependencies, 'values' => $values, 'errors' => $errors)) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/task_external_link/find.php b/app/Template/task_external_link/find.php
new file mode 100644
index 00000000..36a031d3
--- /dev/null
+++ b/app/Template/task_external_link/find.php
@@ -0,0 +1,28 @@
+<div class="page-header">
+ <h2><?= t('Add a new external link') ?></h2>
+</div>
+
+<form class="popover-form" action="<?= $this->url->href('TaskExternalLink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('task_id', array('task_id' => $task['id'])) ?>
+
+ <?= $this->form->label(t('External link'), 'text') ?>
+ <?= $this->form->text(
+ 'text',
+ $values,
+ $errors,
+ array(
+ 'required',
+ 'autofocus',
+ 'placeholder="'.t('Copy and paste your link here...').'"',
+ )) ?>
+
+ <?= $this->form->label(t('Link type'), 'type') ?>
+ <?= $this->form->select('type', $types, $values) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Next') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/task_external_link/form.php b/app/Template/task_external_link/form.php
new file mode 100644
index 00000000..932ca521
--- /dev/null
+++ b/app/Template/task_external_link/form.php
@@ -0,0 +1,13 @@
+<?= $this->form->csrf() ?>
+<?= $this->form->hidden('task_id', array('task_id' => $task['id'])) ?>
+<?= $this->form->hidden('id', $values) ?>
+<?= $this->form->hidden('link_type', $values) ?>
+
+<?= $this->form->label(t('URL'), 'url') ?>
+<?= $this->form->text('url', $values, $errors, array('required')) ?>
+
+<?= $this->form->label(t('Title'), 'title') ?>
+<?= $this->form->text('title', $values, $errors, array('required')) ?>
+
+<?= $this->form->label(t('Dependency'), 'dependency') ?>
+<?= $this->form->select('dependency', $dependencies, $values, $errors) ?>
diff --git a/app/Template/task_external_link/remove.php b/app/Template/task_external_link/remove.php
new file mode 100644
index 00000000..01535255
--- /dev/null
+++ b/app/Template/task_external_link/remove.php
@@ -0,0 +1,15 @@
+<div class="page-header">
+ <h2><?= t('Remove a link') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this link: "%s"?', $link['title']) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'TaskExternalLink', 'remove', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TaskExternalLink', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Template/task_external_link/show.php b/app/Template/task_external_link/show.php
new file mode 100644
index 00000000..12d8e4f6
--- /dev/null
+++ b/app/Template/task_external_link/show.php
@@ -0,0 +1,50 @@
+<div class="page-header">
+ <h2><?= t('External links') ?></h2>
+</div>
+
+<?php if (empty($links)): ?>
+ <p class="alert"><?= t('There is no external link for the moment.') ?></p>
+<?php else: ?>
+ <table class="table-stripped table-small">
+ <tr>
+ <th class="column-10"><?= t('Type') ?></th>
+ <th><?= t('Title') ?></th>
+ <th class="column-10"><?= t('Dependency') ?></th>
+ <th class="column-15"><?= t('Creator') ?></th>
+ <th class="column-15"><?= t('Date') ?></th>
+ <?php if ($this->user->hasProjectAccess('TaskExternalLink', 'edit', $task['project_id'])): ?>
+ <th class="column-5"><?= t('Action') ?></th>
+ <?php endif ?>
+ </tr>
+ <?php foreach ($links as $link): ?>
+ <tr>
+ <td>
+ <?= $link['type'] ?>
+ </td>
+ <td>
+ <a href="<?= $link['url'] ?>" target="_blank"><?= $this->e($link['title']) ?></a>
+ </td>
+ <td>
+ <?= $this->e($link['dependency_label']) ?>
+ </td>
+ <td>
+ <?= $this->e($link['creator_name'] ?: $link['creator_username']) ?>
+ </td>
+ <td>
+ <?= $this->dt->date($link['date_creation']) ?>
+ </td>
+ <?php if ($this->user->hasProjectAccess('TaskExternalLink', 'edit', $task['project_id'])): ?>
+ <td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li><?= $this->url->link(t('Edit'), 'TaskExternalLink', 'edit', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
+ <li><?= $this->url->link(t('Remove'), 'TaskExternalLink', 'confirm', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'popover') ?></li>
+ </ul>
+ </div>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ </table>
+<?php endif ?>
diff --git a/app/Template/task_file/create.php b/app/Template/task_file/create.php
new file mode 100644
index 00000000..f03ce8dc
--- /dev/null
+++ b/app/Template/task_file/create.php
@@ -0,0 +1,33 @@
+<div class="page-header">
+ <h2><?= t('Attach a document') ?></h2>
+</div>
+<div id="file-done" style="display:none">
+ <p class="alert alert-success">
+ <?= t('All files have been uploaded successfully.') ?>
+ <?= $this->url->link(t('View uploaded files'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </p>
+</div>
+
+<div id="file-error-max-size" style="display:none">
+ <p class="alert alert-error">
+ <?= t('The maximum allowed file size is %sB.', $this->text->bytes($max_size)) ?>
+ <a href="#" id="file-browser"><?= t('Choose files again') ?></a>
+ </p>
+</div>
+
+<div
+ id="file-dropzone"
+ data-max-size="<?= $max_size ?>"
+ data-url="<?= $this->url->href('TaskFile', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>">
+ <div id="file-dropzone-inner">
+ <?= t('Drag and drop your files here') ?> <?= t('or') ?> <a href="#" id="file-browser"><?= t('choose files') ?></a>
+ </div>
+</div>
+
+<input type="file" name="files[]" multiple style="display:none" id="file-form-element">
+
+<div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" id="file-upload-button" disabled>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
+</div>
diff --git a/app/Template/file/remove.php b/app/Template/task_file/remove.php
index 37f648eb..5e6c83f2 100644
--- a/app/Template/file/remove.php
+++ b/app/Template/task_file/remove.php
@@ -8,8 +8,8 @@
</p>
<div class="form-actions">
- <?= $this->url->link(t('Yes'), 'file', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), true, 'btn btn-red') ?>
+ <?= $this->url->link(t('Yes'), 'TaskFile', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</div> \ No newline at end of file
diff --git a/app/Template/file/screenshot.php b/app/Template/task_file/screenshot.php
index 73b72eae..72214362 100644
--- a/app/Template/file/screenshot.php
+++ b/app/Template/task_file/screenshot.php
@@ -6,7 +6,7 @@
<p id="screenshot-inner"><?= t('Take a screenshot and press CTRL+V or ⌘+V to paste here.') ?></p>
</div>
-<form action="<?= $this->url->href('file', 'screenshot', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'redirect' => $redirect)) ?>" method="post">
+<form class="popover-form" action="<?= $this->url->href('TaskFile', 'screenshot', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post">
<input type="hidden" name="screenshot"/>
<?= $this->form->csrf() ?>
<div class="form-actions">
diff --git a/app/Template/task_file/show.php b/app/Template/task_file/show.php
new file mode 100644
index 00000000..c3f2bb98
--- /dev/null
+++ b/app/Template/task_file/show.php
@@ -0,0 +1,90 @@
+<?php if (! empty($files) || ! empty($images)): ?>
+<div id="attachments" class="task-show-section">
+
+ <div class="page-header">
+ <h2><?= t('Attachments') ?></h2>
+ </div>
+ <?php if (! empty($images)): ?>
+ <div class="file-thumbnails">
+ <?php foreach ($images as $file): ?>
+ <div class="file-thumbnail">
+ <a href="<?= $this->url->href('FileViewer', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>" class="popover"><img src="<?= $this->url->href('FileViewer', 'thumbnail', array('file_id' => $file['id'], 'project_id' => $task['project_id'], 'task_id' => $file['task_id'])) ?>" title="<?= $this->e($file['name']) ?>" alt="<?= $this->e($file['name']) ?>"></a>
+ <div class="file-thumbnail-content">
+ <div class="file-thumbnail-title">
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-text"><?= $this->e($file['name']) ?> <i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <i class="fa fa-download fa-fw"></i>
+ <?= $this->url->link(t('Download'), 'FileViewer', 'download', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
+ </li>
+ <?php if ($this->user->hasProjectAccess('TaskFile', 'remove', $task['project_id'])): ?>
+ <li>
+ <i class="fa fa-trash fa-fw"></i>
+ <?= $this->url->link(t('Remove'), 'TaskFile', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ </div>
+ <div class="file-thumbnail-description">
+ <span class="tooltip" title='<?= t('Uploaded: %s', $this->dt->datetime($file['date'])).'<br>'.t('Size: %s', $this->text->bytes($file['size'])) ?>'>
+ <i class="fa fa-info-circle"></i>
+ </span>
+ <?= t('Uploaded by %s', $file['user_name'] ?: $file['username']) ?>
+ </div>
+ </div>
+ </div>
+ <?php endforeach ?>
+ </div>
+ <?php endif ?>
+
+ <?php if (! empty($files)): ?>
+ <table class="table-stripped">
+ <tr>
+ <th><?= t('Filename') ?></th>
+ <th><?= t('Creator') ?></th>
+ <th><?= t('Date') ?></th>
+ <th><?= t('Size') ?></th>
+ </tr>
+ <?php foreach ($files as $file): ?>
+ <tr>
+ <td>
+ <i class="fa <?= $this->file->icon($file['name']) ?> fa-fw"></i>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-text"><?= $this->e($file['name']) ?> <i class="fa fa-caret-down"></i></a>
+ <ul>
+ <?php if ($this->file->getPreviewType($file['name']) !== null): ?>
+ <li>
+ <i class="fa fa-eye fa-fw"></i>
+ <?= $this->url->link(t('View file'), 'FileViewer', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ <li>
+ <i class="fa fa-download fa-fw"></i>
+ <?= $this->url->link(t('Download'), 'FileViewer', 'download', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id'])) ?>
+ </li>
+ <?php if ($this->user->hasProjectAccess('TaskFile', 'remove', $task['project_id'])): ?>
+ <li>
+ <i class="fa fa-trash fa-fw"></i>
+ <?= $this->url->link(t('Remove'), 'TaskFile', 'confirm', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'file_id' => $file['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </div>
+ </td>
+ <td>
+ <?= $this->e($file['user_name'] ?: $file['username']) ?>
+ </td>
+ <td>
+ <?= $this->dt->date($file['date']) ?>
+ </td>
+ <td>
+ <?= $this->text->bytes($file['size']) ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+</div>
+<?php endif ?> \ No newline at end of file
diff --git a/app/Template/task_modification/edit_description.php b/app/Template/task_modification/edit_description.php
index 4cae939c..f5a9b0e1 100644
--- a/app/Template/task_modification/edit_description.php
+++ b/app/Template/task_modification/edit_description.php
@@ -2,7 +2,7 @@
<h2><?= t('Edit the description') ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('taskmodification', 'description', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'ajax' => $ajax)) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('taskmodification', 'updateDescription', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('id', $values) ?>
@@ -17,7 +17,17 @@
</li>
</ul>
<div class="write-area">
- <?= $this->form->textarea('description', $values, $errors, array('autofocus', 'placeholder="'.t('Leave a description').'"'), 'task-show-description-textarea') ?>
+ <?= $this->form->textarea(
+ 'description',
+ $values,
+ $errors,
+ array(
+ 'autofocus',
+ 'placeholder="'.t('Leave a description').'"',
+ 'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $task['project_id'])).'"'
+ ),
+ 'task-show-description-textarea'
+ ) ?>
</div>
<div class="preview-area">
<div class="markdown"></div>
@@ -29,10 +39,6 @@
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
<?= t('or') ?>
- <?php if ($ajax): ?>
- <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $task['project_id'])) ?>
- <?php else: ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- <?php endif ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/task_modification/edit_task.php b/app/Template/task_modification/edit_task.php
index f4d7449a..9e98ad5e 100644
--- a/app/Template/task_modification/edit_task.php
+++ b/app/Template/task_modification/edit_task.php
@@ -1,61 +1,35 @@
<div class="page-header">
<h2><?= t('Edit a task') ?></h2>
</div>
-<form id="task-form" method="post" action="<?= $this->url->href('taskmodification', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+<form class="popover-form" method="post" action="<?= $this->url->href('taskmodification', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('project_id', $values) ?>
<div class="form-column">
-
<?= $this->form->label(t('Title'), 'title') ?>
- <?= $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>
- </li>
- <li class="form-tab">
- <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
- </li>
- </ul>
- </div>
-
- <?= $this->render('task/color_picker', array('colors_list' => $colors_list, 'values' => $values)) ?>
+ <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"')) ?>
+ <?= $this->task->selectAssignee($users_list, $values, $errors) ?>
+ <?= $this->task->selectCategory($categories_list, $values, $errors) ?>
+ <?= $this->task->selectPriority($project, $values) ?>
+ <?= $this->task->selectScore($values, $errors) ?>
</div>
<div class="form-column">
- <?= $this->form->hidden('id', $values) ?>
- <?= $this->form->hidden('project_id', $values) ?>
-
- <?= $this->form->label(t('Assignee'), 'owner_id') ?>
- <?= $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, array('tabindex="4"')) ?><br/>
-
- <?= $this->form->label(t('Complexity'), 'score') ?>
- <?= $this->form->number('score', $values, $errors, array('tabindex="6"')) ?><br/>
+ <?= $this->task->selectTimeEstimated($values, $errors) ?>
+ <?= $this->task->selectTimeSpent($values, $errors) ?>
+ <?= $this->task->selectStartDate($values, $errors) ?>
+ <?= $this->task->selectDueDate($values, $errors) ?>
+ </div>
- <?= $this->form->label(t('Due Date'), 'date_due') ?>
- <?= $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 class="form-clear">
+ <?= $this->render('task/color_picker', array('colors_list' => $colors_list, 'values' => $values)) ?>
</div>
<div class="form-actions">
<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') ?>
- <?php else: ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- <?php endif ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/task_modification/edit_time.php b/app/Template/task_modification/edit_time.php
deleted file mode 100644
index 8e7f9b42..00000000
--- a/app/Template/task_modification/edit_time.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<form method="post" action="<?= $this->url->href('taskmodification', 'time', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" class="form-inline task-time-form" autocomplete="off">
-
- <?php if (empty($values['date_started'])): ?>
- <?= $this->url->link('<i class="fa fa-play"></i>', 'taskmodification', 'start', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'task-show-start-link', t('Set automatically the start date')) ?>
- <?php endif ?>
-
- <?= $this->form->csrf() ?>
- <?= $this->form->hidden('id', $values) ?>
-
- <?= $this->form->label(t('Start date'), 'date_started') ?>
- <?= $this->form->text('date_started', $values, array(), array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-datetime') ?>
-
- <?= $this->form->label(t('Time estimated'), 'time_estimated') ?>
- <?= $this->form->numeric('time_estimated', $values, array(), array('placeholder="'.t('hours').'"')) ?>
-
- <?= $this->form->label(t('Time spent'), 'time_spent') ?>
- <?= $this->form->numeric('time_spent', $values, array(), array('placeholder="'.t('hours').'"')) ?>
-
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
-</form> \ No newline at end of file
diff --git a/app/Template/task_modification/edit_recurrence.php b/app/Template/task_recurrence/edit.php
index dc4faa7a..ce69aade 100644
--- a/app/Template/task_modification/edit_recurrence.php
+++ b/app/Template/task_recurrence/edit.php
@@ -4,7 +4,7 @@
<?php if ($task['recurrence_status'] != \Kanboard\Model\Task::RECURRING_STATUS_NONE): ?>
<div class="listing">
- <?= $this->render('task/recurring_info', array(
+ <?= $this->render('task_recurrence/info', array(
'task' => $task,
'recurrence_trigger_list' => $recurrence_trigger_list,
'recurrence_timeframe_list' => $recurrence_timeframe_list,
@@ -15,7 +15,7 @@
<?php if ($task['recurrence_status'] != \Kanboard\Model\Task::RECURRING_STATUS_PROCESSED): ?>
- <form method="post" action="<?= $this->url->href('taskmodification', 'recurrence', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+ <form class="popover-form" method="post" action="<?= $this->url->href('TaskRecurrence', 'update', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -38,9 +38,9 @@
<?= $this->form->select('recurrence_basedate', $recurrence_basedate_list, $values, $errors) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form>
diff --git a/app/Template/task/recurring_info.php b/app/Template/task_recurrence/info.php
index 83ca0960..83ca0960 100644
--- a/app/Template/task/recurring_info.php
+++ b/app/Template/task_recurrence/info.php
diff --git a/app/Template/task_status/close.php b/app/Template/task_status/close.php
index d32863bd..7d200544 100644
--- a/app/Template/task_status/close.php
+++ b/app/Template/task_status/close.php
@@ -8,7 +8,7 @@
</p>
<div class="form-actions">
- <?= $this->url->link(t('Yes'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'confirmation' => 'yes', 'redirect' => $redirect), true, 'btn btn-red') ?>
+ <?= $this->url->link(t('Yes'), 'taskstatus', 'close', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'confirmation' => 'yes'), true, 'btn btn-red popover-link') ?>
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
diff --git a/app/Template/task_status/open.php b/app/Template/task_status/open.php
index 615b2464..5d19bfbe 100644
--- a/app/Template/task_status/open.php
+++ b/app/Template/task_status/open.php
@@ -8,7 +8,7 @@
</p>
<div class="form-actions">
- <?= $this->url->link(t('Yes'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'confirmation' => 'yes', 'redirect' => $redirect), true, 'btn btn-red') ?>
+ <?= $this->url->link(t('Yes'), 'taskstatus', 'open', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'confirmation' => 'yes'), true, 'btn btn-red popover-link') ?>
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
diff --git a/app/Template/tasklink/create.php b/app/Template/tasklink/create.php
index 749f2968..c0d49191 100644
--- a/app/Template/tasklink/create.php
+++ b/app/Template/tasklink/create.php
@@ -2,7 +2,7 @@
<h2><?= t('Add a new link') ?></h2>
</div>
-<form action="<?= $this->url->href('tasklink', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'ajax' => isset($ajax))) ?>" method="post" autocomplete="off">
+<form class="popover-form" action="<?= $this->url->href('tasklink', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('task_id', array('task_id' => $task['id'])) ?>
@@ -21,17 +21,13 @@
'placeholder="'.t('Start to type task title...').'"',
'title="'.t('Start to type task title...').'"',
'data-dst-field="opposite_task_id"',
- 'data-search-url="'.$this->url->href('app', 'autocomplete', array('exclude_task_id' => $task['id'])).'"',
+ 'data-search-url="'.$this->url->href('TaskHelper', 'autocomplete', array('exclude_task_id' => $task['id'])).'"',
),
- 'task-autocomplete') ?>
+ 'autocomplete') ?>
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
<?= t('or') ?>
- <?php if (isset($ajax)): ?>
- <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $task['project_id']), false, 'close-popover') ?>
- <?php else: ?>
- <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
- <?php endif ?>
+ <?= $this->url->link(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'close-popover') ?>
</div>
</form> \ No newline at end of file
diff --git a/app/Template/tasklink/edit.php b/app/Template/tasklink/edit.php
index 73b43277..896f84c0 100644
--- a/app/Template/tasklink/edit.php
+++ b/app/Template/tasklink/edit.php
@@ -22,9 +22,9 @@
'placeholder="'.t('Start to type task title...').'"',
'title="'.t('Start to type task title...').'"',
'data-dst-field="opposite_task_id"',
- 'data-search-url="'.$this->url->href('app', 'autocomplete', array('exclude_task_id' => $task['id'])).'"',
+ 'data-search-url="'.$this->url->href('TaskHelper', 'autocomplete', array('exclude_task_id' => $task['id'])).'"',
),
- 'task-autocomplete') ?>
+ 'autocomplete') ?>
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
diff --git a/app/Template/tasklink/show.php b/app/Template/tasklink/show.php
index 97a3a767..fd8b37a6 100644
--- a/app/Template/tasklink/show.php
+++ b/app/Template/tasklink/show.php
@@ -1,16 +1,18 @@
-<?php if (! empty($links)): ?>
<div class="page-header">
- <h2><?= t('Links') ?></h2>
+ <h2><?= t('Internal links') ?></h2>
</div>
-<table id="links">
+<?php if (empty($links)): ?>
+ <p class="alert"><?= t('There is no internal link for the moment.') ?></p>
+<?php else: ?>
+<table id="links" class="table-small table-stripped">
<tr>
<th class="column-20"><?= t('Label') ?></th>
<th class="column-30"><?= t('Task') ?></th>
<th class="column-20"><?= t('Project') ?></th>
<th><?= t('Column') ?></th>
<th><?= t('Assignee') ?></th>
- <?php if (! isset($not_editable)): ?>
- <th><?= t('Action') ?></th>
+ <?php if ($editable && $this->user->hasProjectAccess('Tasklink', 'edit', $task['project_id'])): ?>
+ <th class="column-5"><?= t('Action') ?></th>
<?php endif ?>
</tr>
<?php foreach ($links as $label => $grouped_links): ?>
@@ -23,12 +25,12 @@
<?php endif ?>
<td>
- <?php if (! isset($not_editable)): ?>
+ <?php if ($is_public): ?>
<?= $this->url->link(
$this->e('#'.$link['task_id'].' '.$link['title']),
'task',
- 'show',
- array('task_id' => $link['task_id'], 'project_id' => $link['project_id']),
+ 'readonly',
+ array('task_id' => $link['task_id'], 'token' => $project['token']),
false,
$link['is_active'] ? '' : 'task-link-closed'
) ?>
@@ -36,14 +38,14 @@
<?= $this->url->link(
$this->e('#'.$link['task_id'].' '.$link['title']),
'task',
- 'readonly',
- array('task_id' => $link['task_id'], 'token' => $project['token']),
+ 'show',
+ array('task_id' => $link['task_id'], 'project_id' => $link['project_id']),
false,
$link['is_active'] ? '' : 'task-link-closed'
) ?>
<?php endif ?>
- <br/>
+ <br>
<?php if (! empty($link['task_time_spent'])): ?>
<strong><?= $this->e($link['task_time_spent']).'h' ?></strong> <?= t('spent') ?>
@@ -57,19 +59,22 @@
<td><?= $this->e($link['column_title']) ?></td>
<td>
<?php if (! empty($link['task_assignee_username'])): ?>
- <?php if (! isset($not_editable)): ?>
+ <?php if ($editable): ?>
<?= $this->url->link($this->e($link['task_assignee_name'] ?: $link['task_assignee_username']), 'user', 'show', array('user_id' => $link['task_assignee_id'])) ?>
<?php else: ?>
<?= $this->e($link['task_assignee_name'] ?: $link['task_assignee_username']) ?>
<?php endif ?>
<?php endif ?>
</td>
- <?php if (! isset($not_editable)): ?>
+ <?php if ($editable && $this->user->hasProjectAccess('Tasklink', 'edit', $task['project_id'])): ?>
<td>
+ <div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
<ul>
<li><?= $this->url->link(t('Edit'), 'tasklink', 'edit', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id'])) ?></li>
<li><?= $this->url->link(t('Remove'), 'tasklink', 'confirm', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id'])) ?></li>
</ul>
+ </div>
</td>
<?php endif ?>
</tr>
@@ -77,7 +82,7 @@
<?php endforeach ?>
</table>
-<?php if (! isset($not_editable) && isset($link_label_list)): ?>
+<?php if ($editable && isset($link_label_list)): ?>
<form action="<?= $this->url->href('tasklink', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" autocomplete="off">
<?= $this->form->csrf() ?>
@@ -95,9 +100,9 @@
'placeholder="'.t('Start to type task title...').'"',
'title="'.t('Start to type task title...').'"',
'data-dst-field="opposite_task_id"',
- 'data-search-url="'.$this->url->href('app', 'autocomplete', array('exclude_task_id' => $task['id'])).'"',
+ 'data-search-url="'.$this->url->href('TaskHelper', 'autocomplete', array('exclude_task_id' => $task['id'])).'"',
),
- 'task-autocomplete') ?>
+ 'autocomplete') ?>
<input type="submit" value="<?= t('Add') ?>" class="btn btn-blue"/>
</form>
diff --git a/app/Template/twofactor/index.php b/app/Template/twofactor/index.php
index 36b92653..b9ee4b49 100644
--- a/app/Template/twofactor/index.php
+++ b/app/Template/twofactor/index.php
@@ -2,36 +2,14 @@
<h2><?= t('Two factor authentication') ?></h2>
</div>
-<form method="post" action="<?= $this->url->href('twofactor', 'save', array('user_id' => $user['id'])) ?>" autocomplete="off">
-
+<form method="post" action="<?= $this->url->href('twofactor', $user['twofactor_activated'] == 1 ? 'deactivate' : 'show', array('user_id' => $user['id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
- <?= $this->form->checkbox('twofactor_activated', t('Enable/disable two factor authentication'), 1, isset($user['twofactor_activated']) && $user['twofactor_activated'] == 1) ?>
-
- <div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
- </div>
-</form>
-
-<?php if ($user['twofactor_activated'] == 1): ?>
-<div class="listing">
- <p><?= t('Secret key: ') ?><strong><?= $this->e($user['twofactor_secret']) ?></strong> (base32)</p>
- <p><br/><img src="<?= $qrcode_url ?>"/><br/><br/></p>
- <p>
- <?= t('This QR code contains the key URI: ') ?><strong><?= $this->e($key_url) ?></strong>
- <br/><br/>
- <?= t('Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).') ?>
- </p>
-</div>
-
-<h3><?= t('Test your device') ?></h3>
-<form method="post" action="<?= $this->url->href('twofactor', 'test', array('user_id' => $user['id'])) ?>" autocomplete="off">
-
- <?= $this->form->csrf() ?>
- <?= $this->form->label(t('Code'), 'code') ?>
- <?= $this->form->text('code', array(), array(), array('placeholder="123456"'), 'form-numeric') ?>
-
+ <p><?= t('Two-Factor Provider: ') ?><strong><?= $this->e($provider) ?></strong></p>
<div class="form-actions">
- <input type="submit" value="<?= t('Check my code') ?>" class="btn btn-blue"/>
+ <?php if ($user['twofactor_activated'] == 1): ?>
+ <input type="submit" value="<?= t('Disable two-factor authentication') ?>" class="btn btn-red"/>
+ <?php else: ?>
+ <input type="submit" value="<?= t('Enable two-factor authentication') ?>" class="btn btn-blue"/>
+ <?php endif ?>
</div>
</form>
-<?php endif ?>
diff --git a/app/Template/twofactor/show.php b/app/Template/twofactor/show.php
new file mode 100644
index 00000000..d6d8f405
--- /dev/null
+++ b/app/Template/twofactor/show.php
@@ -0,0 +1,31 @@
+<div class="page-header">
+ <h2><?= t('Two factor authentication') ?></h2>
+</div>
+
+<?php if (! empty($secret) || ! empty($qrcode_url) || ! empty($key_url)): ?>
+<div class="listing">
+ <?php if (! empty($secret)): ?>
+ <p><?= t('Secret key: ') ?><strong><?= $this->e($secret) ?></strong></p>
+ <?php endif ?>
+
+ <?php if (! empty($qrcode_url)): ?>
+ <p><br><img src="<?= $qrcode_url ?>"/><br><br></p>
+ <?php endif ?>
+
+ <?php if (! empty($key_url)): ?>
+ <p><?= t('This QR code contains the key URI: ') ?><a href="<?= $this->e($key_url) ?>"><?= $this->e($key_url) ?></a></p>
+ <?php endif ?>
+</div>
+<?php endif ?>
+
+<h3><?= t('Test your device') ?></h3>
+<form method="post" action="<?= $this->url->href('twofactor', 'test', array('user_id' => $user['id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+ <?= $this->form->label(t('Code'), 'code') ?>
+ <?= $this->form->text('code', array(), array(), array('placeholder="123456"', 'autofocus'), 'form-numeric') ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Check my code') ?>" class="btn btn-blue">
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/user/authentication.php b/app/Template/user/authentication.php
index 20c3d372..0c08f3fb 100644
--- a/app/Template/user/authentication.php
+++ b/app/Template/user/authentication.php
@@ -8,14 +8,7 @@
<?= $this->form->hidden('id', $values) ?>
<?= $this->form->hidden('username', $values) ?>
- <?= $this->form->label(t('Google Id'), 'google_id') ?>
- <?= $this->form->text('google_id', $values, $errors) ?>
-
- <?= $this->form->label(t('Github Id'), 'github_id') ?>
- <?= $this->form->text('github_id', $values, $errors) ?>
-
- <?= $this->form->label(t('Gitlab Id'), 'gitlab_id') ?>
- <?= $this->form->text('gitlab_id', $values, $errors) ?>
+ <?= $this->hook->render('template:user:authentication:form', array('values' => $values, 'errors' => $errors, 'user' => $user)) ?>
<?= $this->form->checkbox('is_ldap_user', t('Remote user'), 1, isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1) ?>
<?= $this->form->checkbox('disable_login_form', t('Disallow login form'), 1, isset($values['disable_login_form']) && $values['disable_login_form'] == 1) ?>
diff --git a/app/Template/user/create_local.php b/app/Template/user/create_local.php
index 98c38f0d..38bd7836 100644
--- a/app/Template/user/create_local.php
+++ b/app/Template/user/create_local.php
@@ -12,34 +12,35 @@
<div class="form-column">
<?= $this->form->label(t('Username'), 'username') ?>
- <?= $this->form->text('username', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?><br/>
+ <?= $this->form->text('username', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
<?= $this->form->label(t('Name'), 'name') ?>
- <?= $this->form->text('name', $values, $errors) ?><br/>
+ <?= $this->form->text('name', $values, $errors) ?>
<?= $this->form->label(t('Email'), 'email') ?>
- <?= $this->form->email('email', $values, $errors) ?><br/>
+ <?= $this->form->email('email', $values, $errors) ?>
<?= $this->form->label(t('Password'), 'password') ?>
- <?= $this->form->password('password', $values, $errors, array('required')) ?><br/>
+ <?= $this->form->password('password', $values, $errors, array('required')) ?>
<?= $this->form->label(t('Confirmation'), 'confirmation') ?>
- <?= $this->form->password('confirmation', $values, $errors, array('required')) ?><br/>
+ <?= $this->form->password('confirmation', $values, $errors, array('required')) ?>
</div>
<div class="form-column">
<?= $this->form->label(t('Add project member'), 'project_id') ?>
- <?= $this->form->select('project_id', $projects, $values, $errors) ?><br/>
+ <?= $this->form->select('project_id', $projects, $values, $errors) ?>
<?= $this->form->label(t('Timezone'), 'timezone') ?>
- <?= $this->form->select('timezone', $timezones, $values, $errors) ?><br/>
+ <?= $this->form->select('timezone', $timezones, $values, $errors) ?>
<?= $this->form->label(t('Language'), 'language') ?>
- <?= $this->form->select('language', $languages, $values, $errors) ?><br/>
+ <?= $this->form->select('language', $languages, $values, $errors) ?>
+
+ <?= $this->form->label(t('Role'), 'role') ?>
+ <?= $this->form->select('role', $roles, $values, $errors) ?>
<?= $this->form->checkbox('notifications_enabled', t('Enable email notifications'), 1, isset($values['notifications_enabled']) && $values['notifications_enabled'] == 1 ? true : false) ?>
- <?= $this->form->checkbox('is_admin', t('Administrator'), 1, isset($values['is_admin']) && $values['is_admin'] == 1 ? true : false) ?>
- <?= $this->form->checkbox('is_project_admin', t('Project Administrator'), 1, isset($values['is_project_admin']) && $values['is_project_admin'] == 1 ? true : false) ?>
</div>
<div class="form-actions">
@@ -49,4 +50,4 @@
</div>
</form>
</section>
-</section> \ No newline at end of file
+</section>
diff --git a/app/Template/user/create_remote.php b/app/Template/user/create_remote.php
index 49d1548c..7399a010 100644
--- a/app/Template/user/create_remote.php
+++ b/app/Template/user/create_remote.php
@@ -12,37 +12,31 @@
<div class="form-column">
<?= $this->form->label(t('Username'), 'username') ?>
- <?= $this->form->text('username', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?><br/>
+ <?= $this->form->text('username', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
<?= $this->form->label(t('Name'), 'name') ?>
- <?= $this->form->text('name', $values, $errors) ?><br/>
+ <?= $this->form->text('name', $values, $errors) ?>
<?= $this->form->label(t('Email'), 'email') ?>
- <?= $this->form->email('email', $values, $errors) ?><br/>
+ <?= $this->form->email('email', $values, $errors) ?>
- <?= $this->form->label(t('Google Id'), 'google_id') ?>
- <?= $this->form->text('google_id', $values, $errors) ?><br/>
-
- <?= $this->form->label(t('Github Id'), 'github_id') ?>
- <?= $this->form->text('github_id', $values, $errors) ?><br/>
-
- <?= $this->form->label(t('Gitlab Id'), 'gitlab_id') ?>
- <?= $this->form->text('gitlab_id', $values, $errors) ?><br/>
+ <?= $this->hook->render('template:user:create-remote:form', array('values' => $values, 'errors' => $errors)) ?>
</div>
<div class="form-column">
<?= $this->form->label(t('Add project member'), 'project_id') ?>
- <?= $this->form->select('project_id', $projects, $values, $errors) ?><br/>
+ <?= $this->form->select('project_id', $projects, $values, $errors) ?>
<?= $this->form->label(t('Timezone'), 'timezone') ?>
- <?= $this->form->select('timezone', $timezones, $values, $errors) ?><br/>
+ <?= $this->form->select('timezone', $timezones, $values, $errors) ?>
<?= $this->form->label(t('Language'), 'language') ?>
- <?= $this->form->select('language', $languages, $values, $errors) ?><br/>
+ <?= $this->form->select('language', $languages, $values, $errors) ?>
+
+ <?= $this->form->label(t('Role'), 'role') ?>
+ <?= $this->form->select('role', $roles, $values, $errors) ?>
<?= $this->form->checkbox('notifications_enabled', t('Enable email notifications'), 1, isset($values['notifications_enabled']) && $values['notifications_enabled'] == 1 ? true : false) ?>
- <?= $this->form->checkbox('is_admin', t('Administrator'), 1, isset($values['is_admin']) && $values['is_admin'] == 1 ? true : false) ?>
- <?= $this->form->checkbox('is_project_admin', t('Project Administrator'), 1, isset($values['is_project_admin']) && $values['is_project_admin'] == 1 ? true : false) ?>
<?= $this->form->checkbox('disable_login_form', t('Disallow login form'), 1, isset($values['disable_login_form']) && $values['disable_login_form'] == 1) ?>
</div>
diff --git a/app/Template/user/dropdown.php b/app/Template/user/dropdown.php
new file mode 100644
index 00000000..b74ed6e0
--- /dev/null
+++ b/app/Template/user/dropdown.php
@@ -0,0 +1,27 @@
+<div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <i class="fa fa-user fa-fw"></i>
+ <?= $this->url->link(t('View profile'), 'user', 'show', array('user_id' => $user['id'])) ?>
+ </li>
+ <?php if ($user['is_active'] == 1 && $this->user->hasAccess('UserStatus', 'disable') && ! $this->user->isCurrentUser($user['id'])): ?>
+ <li>
+ <i class="fa fa-times fa-fw"></i>
+ <?= $this->url->link(t('Disable'), 'UserStatus', 'confirmDisable', array('user_id' => $user['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ <?php if ($user['is_active'] == 0 && $this->user->hasAccess('UserStatus', 'enable') && ! $this->user->isCurrentUser($user['id'])): ?>
+ <li>
+ <i class="fa fa-check-square-o fa-fw"></i>
+ <?= $this->url->link(t('Enable'), 'UserStatus', 'confirmEnable', array('user_id' => $user['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ <?php if ($this->user->hasAccess('UserStatus', 'remove') && ! $this->user->isCurrentUser($user['id'])): ?>
+ <li>
+ <i class="fa fa-trash-o fa-fw"></i>
+ <?= $this->url->link(t('Remove'), 'UserStatus', 'confirmRemove', array('user_id' => $user['id']), false, 'popover') ?>
+ </li>
+ <?php endif ?>
+ </ul>
+</div> \ No newline at end of file
diff --git a/app/Template/user/edit.php b/app/Template/user/edit.php
index cd10b2ab..f7f67fb7 100644
--- a/app/Template/user/edit.php
+++ b/app/Template/user/edit.php
@@ -8,23 +8,23 @@
<?= $this->form->hidden('id', $values) ?>
<?= $this->form->label(t('Username'), 'username') ?>
- <?= $this->form->text('username', $values, $errors, array('required', $values['is_ldap_user'] == 1 ? 'readonly' : '', 'maxlength="50"')) ?><br/>
+ <?= $this->form->text('username', $values, $errors, array('required', isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1 ? 'readonly' : '', 'maxlength="50"')) ?>
<?= $this->form->label(t('Name'), 'name') ?>
- <?= $this->form->text('name', $values, $errors) ?><br/>
+ <?= $this->form->text('name', $values, $errors) ?>
<?= $this->form->label(t('Email'), 'email') ?>
- <?= $this->form->email('email', $values, $errors) ?><br/>
+ <?= $this->form->email('email', $values, $errors) ?>
<?= $this->form->label(t('Timezone'), 'timezone') ?>
- <?= $this->form->select('timezone', $timezones, $values, $errors) ?><br/>
+ <?= $this->form->select('timezone', $timezones, $values, $errors) ?>
<?= $this->form->label(t('Language'), 'language') ?>
- <?= $this->form->select('language', $languages, $values, $errors) ?><br/>
+ <?= $this->form->select('language', $languages, $values, $errors) ?>
<?php if ($this->user->isAdmin()): ?>
- <?= $this->form->checkbox('is_admin', t('Administrator'), 1, isset($values['is_admin']) && $values['is_admin'] == 1) ?>
- <?= $this->form->checkbox('is_project_admin', t('Project Administrator'), 1, isset($values['is_project_admin']) && $values['is_project_admin'] == 1) ?>
+ <?= $this->form->label(t('Role'), 'role') ?>
+ <?= $this->form->select('role', $roles, $values, $errors) ?>
<?php endif ?>
<div class="form-actions">
diff --git a/app/Template/user/external.php b/app/Template/user/external.php
index 7a42f38e..22c25af2 100644
--- a/app/Template/user/external.php
+++ b/app/Template/user/external.php
@@ -2,54 +2,10 @@
<h2><?= t('External authentications') ?></h2>
</div>
-<?php if (GOOGLE_AUTH): ?>
- <h3><i class="fa fa-google"></i> <?= t('Google Account') ?></h3>
+<?php $html = $this->hook->render('template:user:external', array('user' => $user)) ?>
- <p class="listing">
- <?php if ($this->user->isCurrentUser($user['id'])): ?>
- <?php if (empty($user['google_id'])): ?>
- <?= $this->url->link(t('Link my Google Account'), 'oauth', 'google', array(), true) ?>
- <?php else: ?>
- <?= $this->url->link(t('Unlink my Google Account'), 'oauth', 'unlink', array('backend' => 'google'), true) ?>
- <?php endif ?>
- <?php else: ?>
- <?= empty($user['google_id']) ? t('No account linked.') : t('Account linked.') ?>
- <?php endif ?>
- </p>
-<?php endif ?>
-
-<?php if (GITHUB_AUTH): ?>
- <h3><i class="fa fa-github"></i> <?= t('Github Account') ?></h3>
-
- <p class="listing">
- <?php if ($this->user->isCurrentUser($user['id'])): ?>
- <?php if (empty($user['github_id'])): ?>
- <?= $this->url->link(t('Link my Github Account'), 'oauth', 'github', array(), true) ?>
- <?php else: ?>
- <?= $this->url->link(t('Unlink my Github Account'), 'oauth', 'unlink', array('backend' => 'github'), true) ?>
- <?php endif ?>
- <?php else: ?>
- <?= empty($user['github_id']) ? t('No account linked.') : t('Account linked.') ?>
- <?php endif ?>
- </p>
-<?php endif ?>
-
-<?php if (GITLAB_AUTH): ?>
- <h3><img src="<?= $this->url->dir() ?>assets/img/gitlab-icon.png"/>&nbsp;<?= t('Gitlab Account') ?></h3>
-
- <p class="listing">
- <?php if ($this->user->isCurrentUser($user['id'])): ?>
- <?php if (empty($user['gitlab_id'])): ?>
- <?= $this->url->link(t('Link my Gitlab Account'), 'oauth', 'gitlab', array(), true) ?>
- <?php else: ?>
- <?= $this->url->link(t('Unlink my Gitlab Account'), 'oauth', 'unlink', array('backend' => 'gitlab'), true) ?>
- <?php endif ?>
- <?php else: ?>
- <?= empty($user['gitlab_id']) ? t('No account linked.') : t('Account linked.') ?>
- <?php endif ?>
- </p>
-<?php endif ?>
-
-<?php if (! GOOGLE_AUTH && ! GITHUB_AUTH && ! GITLAB_AUTH): ?>
+<?php if (empty($html)): ?>
<p class="alert"><?= t('No external authentication enabled.') ?></p>
+<?php else: ?>
+ <?= $html ?>
<?php endif ?>
diff --git a/app/Template/user/index.php b/app/Template/user/index.php
index 4008b920..494c1465 100644
--- a/app/Template/user/index.php
+++ b/app/Template/user/index.php
@@ -1,34 +1,32 @@
<section id="main">
<div class="page-header">
- <?php if ($this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('user', 'create')): ?>
<ul>
<li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New local user'), 'user', 'create') ?></li>
<li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New remote user'), 'user', 'create', array('remote' => 1)) ?></li>
<li><i class="fa fa-upload fa-fw"></i><?= $this->url->link(t('Import'), 'userImport', 'step1') ?></li>
+ <li><i class="fa fa-users fa-fw"></i><?= $this->url->link(t('View all groups'), 'group', 'index') ?></li>
</ul>
<?php endif ?>
</div>
<?php if ($paginator->isEmpty()): ?>
<p class="alert"><?= t('No user') ?></p>
<?php else: ?>
- <table>
+ <table class="table-stripped">
<tr>
- <th><?= $paginator->order(t('Id'), 'id') ?></th>
- <th><?= $paginator->order(t('Username'), 'username') ?></th>
- <th><?= $paginator->order(t('Name'), 'name') ?></th>
- <th><?= $paginator->order(t('Email'), 'email') ?></th>
- <th><?= $paginator->order(t('Administrator'), 'is_admin') ?></th>
- <th><?= $paginator->order(t('Project Administrator'), 'is_project_admin') ?></th>
- <th><?= $paginator->order(t('Two factor authentication'), 'twofactor_activated') ?></th>
- <th><?= $paginator->order(t('Notifications'), 'notifications_enabled') ?></th>
- <th><?= $paginator->order(t('Account type'), 'is_ldap_user') ?></th>
+ <th class="column-18"><?= $paginator->order(t('Username'), 'username') ?></th>
+ <th class="column-18"><?= $paginator->order(t('Name'), 'name') ?></th>
+ <th class="column-15"><?= $paginator->order(t('Email'), 'email') ?></th>
+ <th class="column-15"><?= $paginator->order(t('Role'), 'role') ?></th>
+ <th class="column-10"><?= $paginator->order(t('Two Factor'), 'twofactor_activated') ?></th>
+ <th class="column-10"><?= $paginator->order(t('Account type'), 'is_ldap_user') ?></th>
+ <th class="column-10"><?= $paginator->order(t('Status'), 'is_active') ?></th>
+ <th class="column-5"><?= t('Actions') ?></th>
</tr>
<?php foreach ($paginator->getCollection() as $user): ?>
<tr>
<td>
- <?= $this->url->link('#'.$user['id'], 'user', 'show', array('user_id' => $user['id'])) ?>
- </td>
- <td>
+ <?= '#'.$user['id'] ?>&nbsp;
<?= $this->url->link($this->e($user['username']), 'user', 'show', array('user_id' => $user['id'])) ?>
</td>
<td>
@@ -38,23 +36,23 @@
<a href="mailto:<?= $this->e($user['email']) ?>"><?= $this->e($user['email']) ?></a>
</td>
<td>
- <?= $user['is_admin'] ? t('Yes') : t('No') ?>
+ <?= $this->user->getRoleName($user['role']) ?>
</td>
<td>
- <?= $user['is_project_admin'] ? t('Yes') : t('No') ?>
+ <?= $user['twofactor_activated'] ? t('Yes') : t('No') ?>
</td>
<td>
- <?= $user['twofactor_activated'] ? t('Yes') : t('No') ?>
+ <?= $user['is_ldap_user'] ? t('Remote') : t('Local') ?>
</td>
<td>
- <?php if ($user['notifications_enabled'] == 1): ?>
- <?= t('Enabled') ?>
+ <?php if ($user['is_active'] == 1): ?>
+ <?= t('Active') ?>
<?php else: ?>
- <?= t('Disabled') ?>
+ <?= t('Inactive') ?>
<?php endif ?>
</td>
<td>
- <?= $user['is_ldap_user'] ? t('Remote') : t('Local') ?>
+ <?= $this->render('user/dropdown', array('user' => $user)) ?>
</td>
</tr>
<?php endforeach ?>
diff --git a/app/Template/user/last.php b/app/Template/user/last.php
index 8879466e..d6c86391 100644
--- a/app/Template/user/last.php
+++ b/app/Template/user/last.php
@@ -14,7 +14,7 @@
</tr>
<?php foreach ($last_logins as $login): ?>
<tr>
- <td><?= dt('%B %e, %Y at %k:%M %p', $login['date_creation']) ?></td>
+ <td><?= $this->dt->datetime($login['date_creation']) ?></td>
<td><?= $this->e($login['auth_type']) ?></td>
<td><?= $this->e($login['ip']) ?></td>
<td><?= $this->e($login['user_agent']) ?></td>
diff --git a/app/Template/user/layout.php b/app/Template/user/layout.php
index a27f359b..3a0a5ba6 100644
--- a/app/Template/user/layout.php
+++ b/app/Template/user/layout.php
@@ -1,6 +1,6 @@
<section id="main">
<div class="page-header">
- <?php if ($this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('user', 'create')): ?>
<ul>
<li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('All users'), 'user', 'index') ?></li>
<li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New local user'), 'user', 'create') ?></li>
@@ -13,7 +13,7 @@
<?= $this->render('user/sidebar', array('user' => $user)) ?>
<div class="sidebar-content">
- <?= $user_content_for_layout ?>
+ <?= $content_for_sublayout ?>
</div>
</section>
</section> \ No newline at end of file
diff --git a/app/Template/user/password.php b/app/Template/user/password.php
index 3ef28d33..a24a4ee4 100644
--- a/app/Template/user/password.php
+++ b/app/Template/user/password.php
@@ -9,17 +9,17 @@
<div class="alert alert-error">
<?= $this->form->label(t('Current password for the user "%s"', $this->user->getFullname()), 'current_password') ?>
- <?= $this->form->password('current_password', $values, $errors) ?><br/>
+ <?= $this->form->password('current_password', $values, $errors) ?>
</div>
<?= $this->form->label(t('New password for the user "%s"', $this->user->getFullname($user)), 'password') ?>
- <?= $this->form->password('password', $values, $errors) ?><br/>
+ <?= $this->form->password('password', $values, $errors) ?>
<?= $this->form->label(t('Confirmation'), 'confirmation') ?>
- <?= $this->form->password('confirmation', $values, $errors) ?><br/>
+ <?= $this->form->password('confirmation', $values, $errors) ?>
<div class="form-actions">
- <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue">
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'user', 'show', array('user_id' => $user['id'])) ?>
</div>
diff --git a/app/Template/user/password_reset.php b/app/Template/user/password_reset.php
new file mode 100644
index 00000000..4e9063ef
--- /dev/null
+++ b/app/Template/user/password_reset.php
@@ -0,0 +1,26 @@
+<div class="page-header">
+ <h2><?= t('Last Password Reset') ?></h2>
+</div>
+
+<?php if (empty($tokens)): ?>
+ <p class="alert"><?= t('The password has never been reinitialized.') ?></p>
+<?php else: ?>
+ <table class="table-small table-fixed">
+ <tr>
+ <th class="column-20"><?= t('Creation') ?></th>
+ <th class="column-20"><?= t('Expiration') ?></th>
+ <th class="column-5"><?= t('Active') ?></th>
+ <th class="column-15"><?= t('IP address') ?></th>
+ <th><?= t('User agent') ?></th>
+ </tr>
+ <?php foreach ($tokens as $token): ?>
+ <tr>
+ <td><?= $this->dt->datetime($token['date_creation']) ?></td>
+ <td><?= $this->dt->datetime($token['date_expiration']) ?></td>
+ <td><?= $token['is_active'] == 0 ? t('No') : t('Yes') ?></td>
+ <td><?= $this->e($token['ip']) ?></td>
+ <td><?= $this->e($token['user_agent']) ?></td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+<?php endif ?> \ No newline at end of file
diff --git a/app/Template/user/profile.php b/app/Template/user/profile.php
new file mode 100644
index 00000000..176a1491
--- /dev/null
+++ b/app/Template/user/profile.php
@@ -0,0 +1,8 @@
+<section id="main">
+ <br>
+ <ul class="listing">
+ <li><?= t('Username:') ?> <strong><?= $this->e($user['username']) ?></strong></li>
+ <li><?= t('Name:') ?> <strong><?= $this->e($user['name']) ?: t('None') ?></strong></li>
+ <li><?= t('Email:') ?> <strong><?= $this->e($user['email']) ?: t('None') ?></strong></li>
+ </ul>
+</section> \ No newline at end of file
diff --git a/app/Template/user/sessions.php b/app/Template/user/sessions.php
index eabf3672..8db02430 100644
--- a/app/Template/user/sessions.php
+++ b/app/Template/user/sessions.php
@@ -15,11 +15,11 @@
</tr>
<?php foreach ($sessions as $session): ?>
<tr>
- <td><?= dt('%B %e, %Y at %k:%M %p', $session['date_creation']) ?></td>
- <td><?= dt('%B %e, %Y at %k:%M %p', $session['expiration']) ?></td>
+ <td><?= $this->dt->datetime($session['date_creation']) ?></td>
+ <td><?= $this->dt->datetime($session['expiration']) ?></td>
<td><?= $this->e($session['ip']) ?></td>
<td><?= $this->e($session['user_agent']) ?></td>
- <td><?= $this->url->link(t('Remove'), 'user', 'removeSession', array('user_id' => $user['id'], 'id' => $session['id']), true) ?></td>
+ <td><?= $this->url->link(t('Remove'), 'User', 'removeSession', array('user_id' => $user['id'], 'id' => $session['id']), true) ?></td>
</tr>
<?php endforeach ?>
</table>
diff --git a/app/Template/user/show.php b/app/Template/user/show.php
index 220ad87e..9da56666 100644
--- a/app/Template/user/show.php
+++ b/app/Template/user/show.php
@@ -5,13 +5,14 @@
<li><?= t('Username:') ?> <strong><?= $this->e($user['username']) ?></strong></li>
<li><?= t('Name:') ?> <strong><?= $this->e($user['name']) ?: t('None') ?></strong></li>
<li><?= t('Email:') ?> <strong><?= $this->e($user['email']) ?: t('None') ?></strong></li>
+ <li><?= t('Status:') ?> <strong><?= $user['is_active'] ? t('Active') : t('Inactive') ?></strong></li>
</ul>
<div class="page-header">
<h2><?= t('Security') ?></h2>
</div>
<ul class="listing">
- <li><?= t('Group:') ?> <strong><?= $user['is_admin'] ? t('Administrator') : ($user['is_project_admin'] ? t('Project Administrator') : t('Regular user')) ?></strong></li>
+ <li><?= t('Role:') ?> <strong><?= $this->user->getRoleName($user['role']) ?></strong></li>
<li><?= t('Account type:') ?> <strong><?= $user['is_ldap_user'] ? t('Remote') : t('Local') ?></strong></li>
<li><?= $user['twofactor_activated'] == 1 ? t('Two factor authentication enabled') : t('Two factor authentication disabled') ?></li>
</ul>
diff --git a/app/Template/user/sidebar.php b/app/Template/user/sidebar.php
index 167c8054..20fd2ad2 100644
--- a/app/Template/user/sidebar.php
+++ b/app/Template/user/sidebar.php
@@ -1,80 +1,80 @@
<div class="sidebar">
<h2><?= t('Information') ?></h2>
<ul>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'show' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Summary'), 'user', 'show', array('user_id' => $user['id'])) ?>
- </li>
+ <?php if ($this->user->hasAccess('user', 'show')): ?>
+ <li <?= $this->app->checkMenuSelection('user', 'show') ?>>
+ <?= $this->url->link(t('Summary'), 'user', 'show', array('user_id' => $user['id'])) ?>
+ </li>
+ <?php endif ?>
<?php if ($this->user->isAdmin()): ?>
<li>
<?= $this->url->link(t('User dashboard'), 'app', 'index', array('user_id' => $user['id'])) ?>
</li>
<?php endif ?>
<?php if ($this->user->isAdmin() || $this->user->isCurrentUser($user['id'])): ?>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'timesheet' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'timesheet') ?>>
<?= $this->url->link(t('Time tracking'), 'user', 'timesheet', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'last' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'last') ?>>
<?= $this->url->link(t('Last logins'), 'user', 'last', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'sessions' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'sessions') ?>>
<?= $this->url->link(t('Persistent connections'), 'user', 'sessions', array('user_id' => $user['id'])) ?>
</li>
+ <li <?= $this->app->checkMenuSelection('user', 'passwordReset') ?>>
+ <?= $this->url->link(t('Password reset history'), 'user', 'passwordReset', array('user_id' => $user['id'])) ?>
+ </li>
<?php endif ?>
- <?= $this->hook->render('template:user:sidebar:information') ?>
+ <?= $this->hook->render('template:user:sidebar:information', array('user' => $user)) ?>
</ul>
<h2><?= t('Actions') ?></h2>
<ul>
<?php if ($this->user->isAdmin() || $this->user->isCurrentUser($user['id'])): ?>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'edit' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Edit profile'), 'user', 'edit', array('user_id' => $user['id'])) ?>
- </li>
+
+ <?php if ($this->user->hasAccess('user', 'edit')): ?>
+ <li <?= $this->app->checkMenuSelection('user', 'edit') ?>>
+ <?= $this->url->link(t('Edit profile'), 'user', 'edit', array('user_id' => $user['id'])) ?>
+ </li>
+ <?php endif ?>
<?php if ($user['is_ldap_user'] == 0): ?>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'password' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'password') ?>>
<?= $this->url->link(t('Change password'), 'user', 'password', array('user_id' => $user['id'])) ?>
</li>
<?php endif ?>
<?php if ($this->user->isCurrentUser($user['id'])): ?>
- <li <?= $this->app->getRouterController() === 'twofactor' && $this->app->getRouterAction() === 'index' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('twofactor', 'index') ?>>
<?= $this->url->link(t('Two factor authentication'), 'twofactor', 'index', array('user_id' => $user['id'])) ?>
</li>
- <?php elseif ($this->user->isAdmin() && $user['twofactor_activated'] == 1): ?>
- <li <?= $this->app->getRouterController() === 'twofactor' && $this->app->getRouterAction() === 'disable' ? 'class="active"' : '' ?>>
+ <?php elseif ($this->user->hasAccess('twofactor', 'disable') && $user['twofactor_activated'] == 1): ?>
+ <li <?= $this->app->checkMenuSelection('twofactor', 'disable') ?>>
<?= $this->url->link(t('Two factor authentication'), 'twofactor', 'disable', array('user_id' => $user['id'])) ?>
</li>
<?php endif ?>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'share' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'share') ?>>
<?= $this->url->link(t('Public access'), 'user', 'share', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'notifications' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'notifications') ?>>
<?= $this->url->link(t('Notifications'), 'user', 'notifications', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'external' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'external') ?>>
<?= $this->url->link(t('External accounts'), 'user', 'external', array('user_id' => $user['id'])) ?>
</li>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'integrations' ? 'class="active"' : '' ?>>
+ <li <?= $this->app->checkMenuSelection('user', 'integrations') ?>>
<?= $this->url->link(t('Integrations'), 'user', 'integrations', array('user_id' => $user['id'])) ?>
</li>
<?php endif ?>
- <?php if ($this->user->isAdmin()): ?>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'authentication' ? 'class="active"' : '' ?>>
+ <?php if ($this->user->hasAccess('user', 'authentication')): ?>
+ <li <?= $this->app->checkMenuSelection('user', 'authentication') ?>>
<?= $this->url->link(t('Edit Authentication'), 'user', 'authentication', array('user_id' => $user['id'])) ?>
</li>
<?php endif ?>
<?= $this->hook->render('template:user:sidebar:actions', array('user' => $user)) ?>
-
- <?php if ($this->user->isAdmin() && ! $this->user->isCurrentUser($user['id'])): ?>
- <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'remove' ? 'class="active"' : '' ?>>
- <?= $this->url->link(t('Remove'), 'user', 'remove', array('user_id' => $user['id'])) ?>
- </li>
- <?php endif ?>
</ul>
- <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div>
- <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div>
</div> \ No newline at end of file
diff --git a/app/Template/user/timesheet.php b/app/Template/user/timesheet.php
index 5c0d3af8..4a6e42c5 100644
--- a/app/Template/user/timesheet.php
+++ b/app/Template/user/timesheet.php
@@ -18,8 +18,8 @@
<tr>
<td><?= $this->url->link($this->e($record['task_title']), 'task', 'show', array('project_id' => $record['project_id'], 'task_id' => $record['task_id'])) ?></td>
<td><?= $this->url->link($this->e($record['subtask_title']), 'task', 'show', array('project_id' => $record['project_id'], 'task_id' => $record['task_id'])) ?></td>
- <td><?= dt('%B %e, %Y at %k:%M %p', $record['start']) ?></td>
- <td><?= dt('%B %e, %Y at %k:%M %p', $record['end']) ?></td>
+ <td><?= $this->dt->datetime($record['start']) ?></td>
+ <td><?= $this->dt->datetime($record['end']) ?></td>
<td><?= n($record['time_spent']).' '.t('hours') ?></td>
</tr>
<?php endforeach ?>
diff --git a/app/Template/user_import/step1.php b/app/Template/user_import/step1.php
index 7256bfa6..69643d6d 100644
--- a/app/Template/user_import/step1.php
+++ b/app/Template/user_import/step1.php
@@ -1,6 +1,6 @@
<section id="main">
<div class="page-header">
- <?php if ($this->user->isAdmin()): ?>
+ <?php if ($this->user->hasAccess('user', 'create')): ?>
<ul>
<li><i class="fa fa-user fa-fw"></i><?= $this->url->link(t('All users'), 'user', 'index') ?></li>
<li><i class="fa fa-plus fa-fw"></i><?= $this->url->link(t('New local user'), 'user', 'create') ?></li>
diff --git a/app/Template/user_status/disable.php b/app/Template/user_status/disable.php
new file mode 100644
index 00000000..90d8c757
--- /dev/null
+++ b/app/Template/user_status/disable.php
@@ -0,0 +1,13 @@
+<div class="page-header">
+ <h2><?= t('Disable user') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info"><?= t('Do you really want to disable this user: "%s"?', $user['name'] ?: $user['username']) ?></p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'UserStatus', 'disable', array('user_id' => $user['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'user', 'index', array(), false, 'close-popover') ?>
+ </div>
+</div>
diff --git a/app/Template/user_status/enable.php b/app/Template/user_status/enable.php
new file mode 100644
index 00000000..cd3d4947
--- /dev/null
+++ b/app/Template/user_status/enable.php
@@ -0,0 +1,13 @@
+<div class="page-header">
+ <h2><?= t('Enable user') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info"><?= t('Do you really want to enable this user: "%s"?', $user['name'] ?: $user['username']) ?></p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'UserStatus', 'enable', array('user_id' => $user['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'user', 'index', array(), false, 'close-popover') ?>
+ </div>
+</div>
diff --git a/app/Template/user/remove.php b/app/Template/user_status/remove.php
index 810a3a3f..cd5c09a6 100644
--- a/app/Template/user/remove.php
+++ b/app/Template/user_status/remove.php
@@ -6,8 +6,8 @@
<p class="alert alert-info"><?= t('Do you really want to remove this user: "%s"?', $user['name'] ?: $user['username']) ?></p>
<div class="form-actions">
- <?= $this->url->link(t('Yes'), 'user', 'remove', array('user_id' => $user['id'], 'confirmation' => 'yes'), true, 'btn btn-red') ?>
+ <?= $this->url->link(t('Yes'), 'UserStatus', 'remove', array('user_id' => $user['id']), true, 'btn btn-red') ?>
<?= t('or') ?>
- <?= $this->url->link(t('cancel'), 'user', 'show', array('user_id' => $user['id'])) ?>
+ <?= $this->url->link(t('cancel'), 'user', 'index', array(), false, 'close-popover') ?>
</div>
-</div> \ No newline at end of file
+</div>
diff --git a/app/User/DatabaseUserProvider.php b/app/User/DatabaseUserProvider.php
new file mode 100644
index 00000000..fc626610
--- /dev/null
+++ b/app/User/DatabaseUserProvider.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Kanboard\User;
+
+use Kanboard\Core\User\UserProviderInterface;
+
+/**
+ * Database User Provider
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class DatabaseUserProvider implements UserProviderInterface
+{
+ /**
+ * User properties
+ *
+ * @access protected
+ * @var array
+ */
+ protected $user = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param array $user
+ */
+ public function __construct(array $user)
+ {
+ $this->user = $user;
+ }
+
+ /**
+ * Return true to allow automatic user creation
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isUserCreationAllowed()
+ {
+ return false;
+ }
+
+ /**
+ * Get internal id
+ *
+ * @access public
+ * @return string
+ */
+ public function getInternalId()
+ {
+ return $this->user['id'];
+ }
+
+ /**
+ * Get external id column name
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalIdColumn()
+ {
+ return '';
+ }
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId()
+ {
+ return '';
+ }
+
+ /**
+ * Get user role
+ *
+ * @access public
+ * @return string
+ */
+ public function getRole()
+ {
+ return '';
+ }
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername()
+ {
+ return '';
+ }
+
+ /**
+ * Get full name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return '';
+ }
+
+ /**
+ * Get user email
+ *
+ * @access public
+ * @return string
+ */
+ public function getEmail()
+ {
+ return '';
+ }
+
+ /**
+ * Get external group ids
+ *
+ * @access public
+ * @return array
+ */
+ public function getExternalGroupIds()
+ {
+ return array();
+ }
+
+ /**
+ * Get extra user attributes
+ *
+ * @access public
+ * @return array
+ */
+ public function getExtraAttributes()
+ {
+ return array();
+ }
+}
diff --git a/app/User/LdapUserProvider.php b/app/User/LdapUserProvider.php
new file mode 100644
index 00000000..3a84bfea
--- /dev/null
+++ b/app/User/LdapUserProvider.php
@@ -0,0 +1,206 @@
+<?php
+
+namespace Kanboard\User;
+
+use Kanboard\Core\User\UserProviderInterface;
+
+/**
+ * LDAP User Provider
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class LdapUserProvider implements UserProviderInterface
+{
+ /**
+ * LDAP DN
+ *
+ * @access protected
+ * @var string
+ */
+ protected $dn;
+
+ /**
+ * LDAP username
+ *
+ * @access protected
+ * @var string
+ */
+ protected $username;
+
+ /**
+ * User name
+ *
+ * @access protected
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Email
+ *
+ * @access protected
+ * @var string
+ */
+ protected $email;
+
+ /**
+ * User role
+ *
+ * @access protected
+ * @var string
+ */
+ protected $role;
+
+ /**
+ * Group LDAP DNs
+ *
+ * @access protected
+ * @var string[]
+ */
+ protected $groupIds;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param string $dn
+ * @param string $username
+ * @param string $name
+ * @param string $email
+ * @param string $role
+ * @param string[]
+ */
+ public function __construct($dn, $username, $name, $email, $role, array $groupIds)
+ {
+ $this->dn = $dn;
+ $this->username = $username;
+ $this->name = $name;
+ $this->email = $email;
+ $this->role = $role;
+ $this->groupIds = $groupIds;
+ }
+
+ /**
+ * Return true to allow automatic user creation
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isUserCreationAllowed()
+ {
+ return LDAP_USER_CREATION;
+ }
+
+ /**
+ * Get internal id
+ *
+ * @access public
+ * @return string
+ */
+ public function getInternalId()
+ {
+ return '';
+ }
+
+ /**
+ * Get external id column name
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalIdColumn()
+ {
+ return 'username';
+ }
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId()
+ {
+ return $this->getUsername();
+ }
+
+ /**
+ * Get user role
+ *
+ * @access public
+ * @return string
+ */
+ public function getRole()
+ {
+ return $this->role;
+ }
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername()
+ {
+ return LDAP_USERNAME_CASE_SENSITIVE ? $this->username : strtolower($this->username);
+ }
+
+ /**
+ * Get full name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Get user email
+ *
+ * @access public
+ * @return string
+ */
+ public function getEmail()
+ {
+ return $this->email;
+ }
+
+ /**
+ * Get groups
+ *
+ * @access public
+ * @return array
+ */
+ public function getExternalGroupIds()
+ {
+ return $this->groupIds;
+ }
+
+ /**
+ * Get extra user attributes
+ *
+ * @access public
+ * @return array
+ */
+ public function getExtraAttributes()
+ {
+ return array(
+ 'is_ldap_user' => 1,
+ );
+ }
+
+ /**
+ * Get User DN
+ *
+ * @access public
+ * @return string
+ */
+ public function getDn()
+ {
+ return $this->dn;
+ }
+}
diff --git a/app/User/OAuthUserProvider.php b/app/User/OAuthUserProvider.php
new file mode 100644
index 00000000..dec26250
--- /dev/null
+++ b/app/User/OAuthUserProvider.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Kanboard\User;
+
+use Kanboard\Core\User\UserProviderInterface;
+
+/**
+ * OAuth User Provider
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+abstract class OAuthUserProvider implements UserProviderInterface
+{
+ /**
+ * Get external id column name
+ *
+ * @access public
+ * @return string
+ */
+ abstract public function getExternalIdColumn();
+
+ /**
+ * User properties
+ *
+ * @access protected
+ * @var array
+ */
+ protected $user = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param array $user
+ */
+ public function __construct(array $user)
+ {
+ $this->user = $user;
+ }
+
+ /**
+ * Return true to allow automatic user creation
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isUserCreationAllowed()
+ {
+ return false;
+ }
+
+ /**
+ * Get internal id
+ *
+ * @access public
+ * @return string
+ */
+ public function getInternalId()
+ {
+ return '';
+ }
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId()
+ {
+ return $this->user['id'];
+ }
+
+ /**
+ * Get user role
+ *
+ * @access public
+ * @return string
+ */
+ public function getRole()
+ {
+ return '';
+ }
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername()
+ {
+ return '';
+ }
+
+ /**
+ * Get full name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->user['name'];
+ }
+
+ /**
+ * Get user email
+ *
+ * @access public
+ * @return string
+ */
+ public function getEmail()
+ {
+ return $this->user['email'];
+ }
+
+ /**
+ * Get external group ids
+ *
+ * @access public
+ * @return array
+ */
+ public function getExternalGroupIds()
+ {
+ return array();
+ }
+
+ /**
+ * Get extra user attributes
+ *
+ * @access public
+ * @return array
+ */
+ public function getExtraAttributes()
+ {
+ return array();
+ }
+}
diff --git a/app/User/ReverseProxyUserProvider.php b/app/User/ReverseProxyUserProvider.php
new file mode 100644
index 00000000..723b8155
--- /dev/null
+++ b/app/User/ReverseProxyUserProvider.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace Kanboard\User;
+
+use Kanboard\Core\User\UserProviderInterface;
+use Kanboard\Core\Security\Role;
+
+/**
+ * Reverse Proxy User Provider
+ *
+ * @package user
+ * @author Frederic Guillot
+ */
+class ReverseProxyUserProvider implements UserProviderInterface
+{
+ /**
+ * Username
+ *
+ * @access protected
+ * @var string
+ */
+ protected $username = '';
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param string $username
+ */
+ public function __construct($username)
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * Return true to allow automatic user creation
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isUserCreationAllowed()
+ {
+ return true;
+ }
+
+ /**
+ * Get internal id
+ *
+ * @access public
+ * @return string
+ */
+ public function getInternalId()
+ {
+ return '';
+ }
+
+ /**
+ * Get external id column name
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalIdColumn()
+ {
+ return 'username';
+ }
+
+ /**
+ * Get external id
+ *
+ * @access public
+ * @return string
+ */
+ public function getExternalId()
+ {
+ return $this->username;
+ }
+
+ /**
+ * Get user role
+ *
+ * @access public
+ * @return string
+ */
+ public function getRole()
+ {
+ return REVERSE_PROXY_DEFAULT_ADMIN === $this->username ? Role::APP_ADMIN : Role::APP_USER;
+ }
+
+ /**
+ * Get username
+ *
+ * @access public
+ * @return string
+ */
+ public function getUsername()
+ {
+ return $this->username;
+ }
+
+ /**
+ * Get full name
+ *
+ * @access public
+ * @return string
+ */
+ public function getName()
+ {
+ return '';
+ }
+
+ /**
+ * Get user email
+ *
+ * @access public
+ * @return string
+ */
+ public function getEmail()
+ {
+ return REVERSE_PROXY_DEFAULT_DOMAIN !== '' ? $this->username.'@'.REVERSE_PROXY_DEFAULT_DOMAIN : '';
+ }
+
+ /**
+ * Get external group ids
+ *
+ * @access public
+ * @return array
+ */
+ public function getExternalGroupIds()
+ {
+ return array();
+ }
+
+ /**
+ * Get extra user attributes
+ *
+ * @access public
+ * @return array
+ */
+ public function getExtraAttributes()
+ {
+ return array(
+ 'is_ldap_user' => 1,
+ 'disable_login_form' => 1,
+ );
+ }
+}
diff --git a/app/Validator/ActionValidator.php b/app/Validator/ActionValidator.php
new file mode 100644
index 00000000..95ee7d21
--- /dev/null
+++ b/app/Validator/ActionValidator.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Action Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class ActionValidator extends Base
+{
+ /**
+ * Validate action creation
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('event_name', t('This value is required')),
+ new Validators\Required('action_name', t('This value is required')),
+ new Validators\Required('params', t('This value is required')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Validator/AuthValidator.php b/app/Validator/AuthValidator.php
new file mode 100644
index 00000000..36ccdff0
--- /dev/null
+++ b/app/Validator/AuthValidator.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Gregwar\Captcha\CaptchaBuilder;
+
+/**
+ * Authentication Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class AuthValidator extends Base
+{
+ /**
+ * Validate user login form
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateForm(array $values)
+ {
+ return $this->executeValidators(array('validateFields', 'validateLocking', 'validateCaptcha', 'validateCredentials'), $values);
+ }
+
+ /**
+ * Validate credentials syntax
+ *
+ * @access protected
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ protected function validateFields(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('username', t('The username is required')),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\Required('password', t('The password is required')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors(),
+ );
+ }
+
+ /**
+ * Validate user locking
+ *
+ * @access protected
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ protected function validateLocking(array $values)
+ {
+ $result = true;
+ $errors = array();
+
+ if ($this->userLocking->isLocked($values['username'])) {
+ $result = false;
+ $errors['login'] = t('Your account is locked for %d minutes', BRUTEFORCE_LOCKDOWN_DURATION);
+ $this->logger->error('Account locked: '.$values['username']);
+ }
+
+ return array($result, $errors);
+ }
+
+ /**
+ * Validate password syntax
+ *
+ * @access protected
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ protected function validateCredentials(array $values)
+ {
+ $result = true;
+ $errors = array();
+
+ if (! $this->authenticationManager->passwordAuthentication($values['username'], $values['password'])) {
+ $result = false;
+ $errors['login'] = t('Bad username or password');
+ }
+
+ return array($result, $errors);
+ }
+
+ /**
+ * Validate captcha
+ *
+ * @access protected
+ * @param array $values Form values
+ * @return boolean
+ */
+ protected function validateCaptcha(array $values)
+ {
+ $result = true;
+ $errors = array();
+
+ if ($this->userLocking->hasCaptcha($values['username'])) {
+ if (! isset($this->sessionStorage->captcha)) {
+ $result = false;
+ } else {
+ $builder = new CaptchaBuilder;
+ $builder->setPhrase($this->sessionStorage->captcha);
+ $result = $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : '');
+
+ if (! $result) {
+ $errors['login'] = t('Invalid captcha');
+ }
+ }
+ }
+
+ return array($result, $errors);;
+ }
+}
diff --git a/app/Validator/Base.php b/app/Validator/Base.php
new file mode 100644
index 00000000..ba32a503
--- /dev/null
+++ b/app/Validator/Base.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validators;
+
+/**
+ * Base Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class Base extends \Kanboard\Core\Base
+{
+ /**
+ * Execute multiple validators
+ *
+ * @access public
+ * @param array $validators List of validators
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function executeValidators(array $validators, array $values)
+ {
+ $result = false;
+ $errors = array();
+
+ foreach ($validators as $method) {
+ list($result, $errors) = $this->$method($values);
+
+ if (! $result) {
+ break;
+ }
+ }
+
+ return array($result, $errors);
+ }
+
+ /**
+ * Common password validation rules
+ *
+ * @access protected
+ * @return array
+ */
+ protected function commonPasswordValidationRules()
+ {
+ return array(
+ new Validators\Required('password', t('The password is required')),
+ new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6),
+ new Validators\Required('confirmation', t('The confirmation is required')),
+ new Validators\Equals('password', 'confirmation', t('Passwords don\'t match')),
+ );
+ }
+}
diff --git a/app/Validator/CategoryValidator.php b/app/Validator/CategoryValidator.php
new file mode 100644
index 00000000..715aed66
--- /dev/null
+++ b/app/Validator/CategoryValidator.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Category Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class CategoryValidator extends Base
+{
+ /**
+ * Validate category creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Required('name', t('The name is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate category modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Required('name', t('The name is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Integer('id', t('The id must be an integer')),
+ new Validators\Integer('project_id', t('The project id must be an integer')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50)
+ );
+ }
+}
diff --git a/app/Validator/ColumnValidator.php b/app/Validator/ColumnValidator.php
new file mode 100644
index 00000000..f0f1659b
--- /dev/null
+++ b/app/Validator/ColumnValidator.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Column Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class ColumnValidator extends Base
+{
+ /**
+ * Validate column modification
+ *
+ * @access public
+ * @param array $values Required parameters to update a column
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('This value is required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate column creation
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Integer('task_limit', t('This value must be an integer')),
+ new Validators\GreaterThan('task_limit', t('This value must be greater than %d', -1), -1),
+ new Validators\Required('title', t('The title is required')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 50), 50),
+ );
+ }
+}
diff --git a/app/Validator/CommentValidator.php b/app/Validator/CommentValidator.php
new file mode 100644
index 00000000..4eb54206
--- /dev/null
+++ b/app/Validator/CommentValidator.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Comment Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class CommentValidator extends Base
+{
+ /**
+ * Validate comment creation
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array(
+ new Validators\Required('task_id', t('This value is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate comment modification
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('This value is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Integer('task_id', t('This value must be an integer')),
+ new Validators\Integer('user_id', t('This value must be an integer')),
+ new Validators\MaxLength('reference', t('The maximum length is %d characters', 50), 50),
+ new Validators\Required('comment', t('Comment is required'))
+ );
+ }
+}
diff --git a/app/Validator/CurrencyValidator.php b/app/Validator/CurrencyValidator.php
new file mode 100644
index 00000000..ee191523
--- /dev/null
+++ b/app/Validator/CurrencyValidator.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Currency Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class CurrencyValidator extends Base
+{
+ /**
+ * Validate
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('currency', t('Field required')),
+ new Validators\Required('rate', t('Field required')),
+ new Validators\Numeric('rate', t('This value must be numeric')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Validator/CustomFilterValidator.php b/app/Validator/CustomFilterValidator.php
new file mode 100644
index 00000000..07f2a1eb
--- /dev/null
+++ b/app/Validator/CustomFilterValidator.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Custom Filter Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class CustomFilterValidator extends Base
+{
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Required('project_id', t('Field required')),
+ new Validators\Required('user_id', t('Field required')),
+ new Validators\Required('name', t('Field required')),
+ new Validators\Required('filter', t('Field required')),
+ new Validators\Integer('user_id', t('This value must be an integer')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 100), 100),
+ new Validators\MaxLength('filter', t('The maximum length is %d characters', 100), 100)
+ );
+ }
+
+ /**
+ * Validate filter creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, $this->commonValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate filter modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('Field required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Validator/ExternalLinkValidator.php b/app/Validator/ExternalLinkValidator.php
new file mode 100644
index 00000000..fff4133b
--- /dev/null
+++ b/app/Validator/ExternalLinkValidator.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * External Link Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class ExternalLinkValidator extends Base
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, $this->commonValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The id is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Required('url', t('Field required')),
+ new Validators\MaxLength('url', t('The maximum length is %d characters', 255), 255),
+ new Validators\Required('title', t('Field required')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 255), 255),
+ new Validators\Required('link_type', t('Field required')),
+ new Validators\MaxLength('link_type', t('The maximum length is %d characters', 100), 100),
+ new Validators\Required('dependency', t('Field required')),
+ new Validators\MaxLength('dependency', t('The maximum length is %d characters', 100), 100),
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Required('task_id', t('Field required')),
+ new Validators\Integer('task_id', t('This value must be an integer')),
+ );
+ }
+}
diff --git a/app/Validator/GroupValidator.php b/app/Validator/GroupValidator.php
new file mode 100644
index 00000000..2226abd3
--- /dev/null
+++ b/app/Validator/GroupValidator.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Kanboard\Model\Group;
+
+/**
+ * Group Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class GroupValidator extends Base
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, $this->commonValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The id is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Required('name', t('The name is required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 100), 100),
+ new Validators\Unique('name', t('The name must be unique'), $this->db->getConnection(), Group::TABLE, 'id'),
+ new Validators\MaxLength('external_id', t('The maximum length is %d characters', 255), 255),
+ new Validators\Integer('id', t('This value must be an integer')),
+ );
+ }
+}
diff --git a/app/Validator/LinkValidator.php b/app/Validator/LinkValidator.php
new file mode 100644
index 00000000..10a826da
--- /dev/null
+++ b/app/Validator/LinkValidator.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Kanboard\Model\Link;
+
+/**
+ * Link Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class LinkValidator extends Base
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('label', t('Field required')),
+ new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), Link::TABLE),
+ new Validators\NotEquals('label', 'opposite_label', t('The labels must be different')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('Field required')),
+ new Validators\Required('opposite_id', t('Field required')),
+ new Validators\Required('label', t('Field required')),
+ new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), Link::TABLE),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Validator/PasswordResetValidator.php b/app/Validator/PasswordResetValidator.php
new file mode 100644
index 00000000..baf2d8d7
--- /dev/null
+++ b/app/Validator/PasswordResetValidator.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Gregwar\Captcha\CaptchaBuilder;
+
+/**
+ * Password Reset Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class PasswordResetValidator extends Base
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ return $this->executeValidators(array('validateFields', 'validateCaptcha'), $values);
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, $this->commonPasswordValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors(),
+ );
+ }
+
+ /**
+ * Validate fields
+ *
+ * @access protected
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ protected function validateFields(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('captcha', t('This value is required')),
+ new Validators\Required('username', t('The username is required')),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors(),
+ );
+ }
+
+ /**
+ * Validate captcha
+ *
+ * @access protected
+ * @param array $values Form values
+ * @return boolean
+ */
+ protected function validateCaptcha(array $values)
+ {
+ $errors = array();
+
+ if (! isset($this->sessionStorage->captcha)) {
+ $result = false;
+ } else {
+ $builder = new CaptchaBuilder;
+ $builder->setPhrase($this->sessionStorage->captcha);
+ $result = $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : '');
+
+ if (! $result) {
+ $errors['captcha'] = array(t('Invalid captcha'));
+ }
+ }
+
+ return array($result, $errors);;
+ }
+}
diff --git a/app/Validator/ProjectValidator.php b/app/Validator/ProjectValidator.php
new file mode 100644
index 00000000..1c6c90f8
--- /dev/null
+++ b/app/Validator/ProjectValidator.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Kanboard\Model\Project;
+
+/**
+ * Project Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class ProjectValidator extends Base
+{
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Integer('priority_default', t('This value must be an integer')),
+ new Validators\Integer('priority_start', t('This value must be an integer')),
+ new Validators\Integer('priority_end', t('This value must be an integer')),
+ new Validators\Integer('is_active', t('This value must be an integer')),
+ new Validators\Required('name', t('The project name is required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50),
+ new Validators\MaxLength('identifier', t('The maximum length is %d characters', 50), 50),
+ new Validators\MaxLength('start_date', t('The maximum length is %d characters', 10), 10),
+ new Validators\MaxLength('end_date', t('The maximum length is %d characters', 10), 10),
+ new Validators\AlphaNumeric('identifier', t('This value must be alphanumeric')) ,
+ new Validators\Unique('identifier', t('The identifier must be unique'), $this->db->getConnection(), Project::TABLE),
+ );
+ }
+
+ /**
+ * Validate project creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ if (! empty($values['identifier'])) {
+ $values['identifier'] = strtoupper($values['identifier']);
+ }
+
+ $v = new Validator($values, $this->commonValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate project modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ if (! empty($values['identifier'])) {
+ $values['identifier'] = strtoupper($values['identifier']);
+ }
+
+ $rules = array(
+ new Validators\Required('id', t('This value is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Validator/SubtaskValidator.php b/app/Validator/SubtaskValidator.php
new file mode 100644
index 00000000..1989b7f4
--- /dev/null
+++ b/app/Validator/SubtaskValidator.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Subtask Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class SubtaskValidator extends Base
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array(
+ new Validators\Required('task_id', t('The task id is required')),
+ new Validators\Required('title', t('The title is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The subtask id is required')),
+ new Validators\Required('task_id', t('The task id is required')),
+ new Validators\Required('title', t('The title is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate API modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateApiModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The subtask id is required')),
+ new Validators\Required('task_id', t('The task id is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Integer('id', t('The subtask id must be an integer')),
+ new Validators\Integer('task_id', t('The task id must be an integer')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 255), 255),
+ new Validators\Integer('user_id', t('The user id must be an integer')),
+ new Validators\Integer('status', t('The status must be an integer')),
+ new Validators\Numeric('time_estimated', t('The time must be a numeric value')),
+ new Validators\Numeric('time_spent', t('The time must be a numeric value')),
+ );
+ }
+}
diff --git a/app/Validator/SwimlaneValidator.php b/app/Validator/SwimlaneValidator.php
new file mode 100644
index 00000000..4cc780f9
--- /dev/null
+++ b/app/Validator/SwimlaneValidator.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Swimlane Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class SwimlaneValidator extends Base
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Required('name', t('The name is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Required('name', t('The name is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate default swimlane modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateDefaultModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Required('default_swimlane', t('The name is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Integer('id', t('The id must be an integer')),
+ new Validators\Integer('project_id', t('The project id must be an integer')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50)
+ );
+ }
+}
diff --git a/app/Validator/TaskLinkValidator.php b/app/Validator/TaskLinkValidator.php
new file mode 100644
index 00000000..c88c2b16
--- /dev/null
+++ b/app/Validator/TaskLinkValidator.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Kanboard\Model\Task;
+
+/**
+ * Task Link Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class TaskLinkValidator extends Base
+{
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Required('task_id', t('Field required')),
+ new Validators\Required('opposite_task_id', t('Field required')),
+ new Validators\Required('link_id', t('Field required')),
+ new Validators\NotEquals('opposite_task_id', 'task_id', t('A task cannot be linked to itself')),
+ new Validators\Exists('opposite_task_id', t('This linked task id doesn\'t exists'), $this->db->getConnection(), Task::TABLE, 'id')
+ );
+ }
+
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, $this->commonValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('Field required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/TaskValidator.php b/app/Validator/TaskValidator.php
index 683cb0b1..1a77dd32 100644
--- a/app/Model/TaskValidator.php
+++ b/app/Validator/TaskValidator.php
@@ -1,14 +1,14 @@
<?php
-namespace Kanboard\Model;
+namespace Kanboard\Validator;
use SimpleValidator\Validator;
use SimpleValidator\Validators;
/**
- * Task validator model
+ * Task Validator
*
- * @package model
+ * @package validator
* @author Frederic Guillot
*/
class TaskValidator extends Base
@@ -37,10 +37,11 @@ class TaskValidator extends Base
new Validators\Integer('recurrence_basedate', t('This value must be an integer')),
new Validators\Integer('recurrence_trigger', t('This value must be an integer')),
new Validators\Integer('recurrence_status', t('This value must be an integer')),
+ new Validators\Integer('priority', t('This value must be an integer')),
new Validators\MaxLength('title', t('The maximum length is %d characters', 200), 200),
new Validators\MaxLength('reference', t('The maximum length is %d characters', 50), 50),
- new Validators\Date('date_due', t('Invalid date'), $this->dateParser->getDateFormats()),
- new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getAllFormats()),
+ new Validators\Date('date_due', t('Invalid date'), $this->dateParser->getDateFormats(true)),
+ new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getDateTimeFormats(true)),
new Validators\Numeric('time_spent', t('This value must be numeric')),
new Validators\Numeric('time_estimated', t('This value must be numeric')),
);
diff --git a/app/Validator/UserValidator.php b/app/Validator/UserValidator.php
new file mode 100644
index 00000000..d85d335f
--- /dev/null
+++ b/app/Validator/UserValidator.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Kanboard\Model\User;
+
+/**
+ * User Validator
+ *
+ * @package validator
+ * @author Frederic Guillot
+ */
+class UserValidator extends Base
+{
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\MaxLength('role', t('The maximum length is %d characters', 25), 25),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), User::TABLE, 'id'),
+ new Validators\Email('email', t('Email address invalid')),
+ new Validators\Integer('is_ldap_user', t('This value must be an integer')),
+ );
+ }
+
+ /**
+ * Validate user creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array(
+ new Validators\Required('username', t('The username is required')),
+ );
+
+ if (isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1) {
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+ } else {
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules(), $this->commonPasswordValidationRules()));
+ }
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate user modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The user id is required')),
+ new Validators\Required('username', t('The username is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate user API modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateApiModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The user id is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate password modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validatePasswordModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The user id is required')),
+ new Validators\Required('current_password', t('The current password is required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonPasswordValidationRules()));
+
+ if ($v->execute()) {
+ if ($this->authenticationManager->passwordAuthentication($this->userSession->getUsername(), $values['current_password'], false)) {
+ return array(true, array());
+ } else {
+ return array(false, array('current_password' => array(t('Wrong password'))));
+ }
+ }
+
+ return array(false, $v->getErrors());
+ }
+}
diff --git a/app/check_setup.php b/app/check_setup.php
index be52a718..eec63ed8 100644
--- a/app/check_setup.php
+++ b/app/check_setup.php
@@ -19,14 +19,29 @@ if (version_compare(PHP_VERSION, '5.4.0', '<')) {
}
}
-// Check extension: PDO
-if (! extension_loaded('pdo_sqlite') && ! extension_loaded('pdo_mysql') && ! extension_loaded('pdo_pgsql')) {
- throw new Exception('PHP extension required: pdo_sqlite or pdo_mysql or pdo_pgsql');
+// Check data folder if sqlite
+if (DB_DRIVER === 'sqlite' && ! is_writable('data')) {
+ throw new Exception('The directory "data" must be writeable by your web server user');
}
-// Check extension: mbstring
-if (! extension_loaded('mbstring')) {
- throw new Exception('PHP extension required: mbstring');
+// Check PDO extensions
+if (DB_DRIVER === 'sqlite' && ! extension_loaded('pdo_sqlite')) {
+ throw new Exception('PHP extension required: "pdo_sqlite"');
+}
+
+if (DB_DRIVER === 'mysql' && ! extension_loaded('pdo_mysql')) {
+ throw new Exception('PHP extension required: "pdo_mysql"');
+}
+
+if (DB_DRIVER === 'postgres' && ! extension_loaded('pdo_pgsql')) {
+ throw new Exception('PHP extension required: "pdo_pgsql"');
+}
+
+// Check other extensions
+foreach (array('gd', 'mbstring', 'hash', 'openssl', 'json', 'hash', 'ctype', 'filter', 'session') as $ext) {
+ if (! extension_loaded($ext)) {
+ throw new Exception('PHP extension required: "'.$ext.'"');
+ }
}
// Fix wrong value for arg_separator.output, used by the function http_build_query()
diff --git a/app/common.php b/app/common.php
index 85a2b7d2..e2fe6aff 100644
--- a/app/common.php
+++ b/app/common.php
@@ -1,6 +1,6 @@
<?php
-require dirname(__DIR__) . '/vendor/autoload.php';
+require __DIR__.'/../vendor/autoload.php';
// Automatically parse environment configuration (Heroku)
if (getenv('DATABASE_URL')) {
@@ -14,22 +14,27 @@ if (getenv('DATABASE_URL')) {
define('DB_NAME', ltrim($dbopts["path"], '/'));
}
-// Include custom config file
if (file_exists('config.php')) {
require 'config.php';
}
+if (file_exists('data'.DIRECTORY_SEPARATOR.'config.php')) {
+ require 'data'.DIRECTORY_SEPARATOR.'config.php';
+}
+
require __DIR__.'/constants.php';
require __DIR__.'/check_setup.php';
$container = new Pimple\Container;
+$container->register(new Kanboard\ServiceProvider\SessionProvider);
$container->register(new Kanboard\ServiceProvider\LoggingProvider);
$container->register(new Kanboard\ServiceProvider\DatabaseProvider);
+$container->register(new Kanboard\ServiceProvider\AuthenticationProvider);
+$container->register(new Kanboard\ServiceProvider\NotificationProvider);
$container->register(new Kanboard\ServiceProvider\ClassProvider);
$container->register(new Kanboard\ServiceProvider\EventDispatcherProvider);
-
-if (ENABLE_URL_REWRITE) {
- require __DIR__.'/routes.php';
-}
-
-$container['pluginLoader']->scan();
+$container->register(new Kanboard\ServiceProvider\GroupProvider);
+$container->register(new Kanboard\ServiceProvider\RouteProvider);
+$container->register(new Kanboard\ServiceProvider\ActionProvider);
+$container->register(new Kanboard\ServiceProvider\ExternalLinkProvider);
+$container->register(new Kanboard\ServiceProvider\PluginProvider);
diff --git a/app/constants.php b/app/constants.php
index 4c22f760..011fa784 100644
--- a/app/constants.php
+++ b/app/constants.php
@@ -23,46 +23,36 @@ defined('DB_HOSTNAME') or define('DB_HOSTNAME', 'localhost');
defined('DB_NAME') or define('DB_NAME', 'kanboard');
defined('DB_PORT') or define('DB_PORT', null);
+// Database backend group provider
+defined('DB_GROUP_PROVIDER') or define('DB_GROUP_PROVIDER', true);
+
// LDAP configuration
defined('LDAP_AUTH') or define('LDAP_AUTH', false);
defined('LDAP_SERVER') or define('LDAP_SERVER', '');
defined('LDAP_PORT') or define('LDAP_PORT', 389);
-defined('LDAP_START_TLS') or define('LDAP_START_TLS', false);
defined('LDAP_SSL_VERIFY') or define('LDAP_SSL_VERIFY', true);
+defined('LDAP_START_TLS') or define('LDAP_START_TLS', false);
+defined('LDAP_USERNAME_CASE_SENSITIVE') or define('LDAP_USERNAME_CASE_SENSITIVE', false);
+
defined('LDAP_BIND_TYPE') or define('LDAP_BIND_TYPE', 'anonymous');
defined('LDAP_USERNAME') or define('LDAP_USERNAME', null);
defined('LDAP_PASSWORD') or define('LDAP_PASSWORD', null);
-defined('LDAP_ACCOUNT_BASE') or define('LDAP_ACCOUNT_BASE', '');
-defined('LDAP_USER_PATTERN') or define('LDAP_USER_PATTERN', '');
-defined('LDAP_ACCOUNT_FULLNAME') or define('LDAP_ACCOUNT_FULLNAME', 'displayname');
-defined('LDAP_ACCOUNT_EMAIL') or define('LDAP_ACCOUNT_EMAIL', 'mail');
-defined('LDAP_ACCOUNT_ID') or define('LDAP_ACCOUNT_ID', '');
-defined('LDAP_ACCOUNT_MEMBEROF') or define('LDAP_ACCOUNT_MEMBEROF', 'memberof');
-defined('LDAP_ACCOUNT_CREATION') or define('LDAP_ACCOUNT_CREATION', true);
+
+defined('LDAP_USER_BASE_DN') or define('LDAP_USER_BASE_DN', '');
+defined('LDAP_USER_FILTER') or define('LDAP_USER_FILTER', '');
+defined('LDAP_USER_ATTRIBUTE_USERNAME') or define('LDAP_USER_ATTRIBUTE_USERNAME', 'uid');
+defined('LDAP_USER_ATTRIBUTE_FULLNAME') or define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn');
+defined('LDAP_USER_ATTRIBUTE_EMAIL') or define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail');
+defined('LDAP_USER_ATTRIBUTE_GROUPS') or define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof');
+defined('LDAP_USER_CREATION') or define('LDAP_USER_CREATION', true);
+
defined('LDAP_GROUP_ADMIN_DN') or define('LDAP_GROUP_ADMIN_DN', '');
-defined('LDAP_GROUP_PROJECT_ADMIN_DN') or define('LDAP_GROUP_PROJECT_ADMIN_DN', '');
-defined('LDAP_USERNAME_CASE_SENSITIVE') or define('LDAP_USERNAME_CASE_SENSITIVE', false);
+defined('LDAP_GROUP_MANAGER_DN') or define('LDAP_GROUP_MANAGER_DN', '');
-// Google authentication
-defined('GOOGLE_AUTH') or define('GOOGLE_AUTH', false);
-defined('GOOGLE_CLIENT_ID') or define('GOOGLE_CLIENT_ID', '');
-defined('GOOGLE_CLIENT_SECRET') or define('GOOGLE_CLIENT_SECRET', '');
-
-// Github authentication
-defined('GITHUB_AUTH') or define('GITHUB_AUTH', false);
-defined('GITHUB_CLIENT_ID') or define('GITHUB_CLIENT_ID', '');
-defined('GITHUB_CLIENT_SECRET') or define('GITHUB_CLIENT_SECRET', '');
-defined('GITHUB_OAUTH_AUTHORIZE_URL') or define('GITHUB_OAUTH_AUTHORIZE_URL', 'https://github.com/login/oauth/authorize');
-defined('GITHUB_OAUTH_TOKEN_URL') or define('GITHUB_OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token');
-defined('GITHUB_API_URL') or define('GITHUB_API_URL', 'https://api.github.com/');
-
-// Gitlab authentication
-defined('GITLAB_AUTH') or define('GITLAB_AUTH', false);
-defined('GITLAB_CLIENT_ID') or define('GITLAB_CLIENT_ID', '');
-defined('GITLAB_CLIENT_SECRET') or define('GITLAB_CLIENT_SECRET', '');
-defined('GITLAB_OAUTH_AUTHORIZE_URL') or define('GITLAB_OAUTH_AUTHORIZE_URL', 'https://gitlab.com/oauth/authorize');
-defined('GITLAB_OAUTH_TOKEN_URL') or define('GITLAB_OAUTH_TOKEN_URL', 'https://gitlab.com/oauth/token');
-defined('GITLAB_API_URL') or define('GITLAB_API_URL', 'https://gitlab.com/api/v3/');
+defined('LDAP_GROUP_PROVIDER') or define('LDAP_GROUP_PROVIDER', false);
+defined('LDAP_GROUP_BASE_DN') or define('LDAP_GROUP_BASE_DN', '');
+defined('LDAP_GROUP_FILTER') or define('LDAP_GROUP_FILTER', '');
+defined('LDAP_GROUP_ATTRIBUTE_NAME') or define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn');
// Proxy authentication
defined('REVERSE_PROXY_AUTH') or define('REVERSE_PROXY_AUTH', false);
@@ -107,6 +97,9 @@ defined('ENABLE_URL_REWRITE') or define('ENABLE_URL_REWRITE', isset($_SERVER['HT
// Hide login form
defined('HIDE_LOGIN_FORM') or define('HIDE_LOGIN_FORM', false);
+// Disabling logout (for external SSO authentication)
+defined('DISABLE_LOGOUT') or define('DISABLE_LOGOUT', false);
+
// Bruteforce protection
defined('BRUTEFORCE_CAPTCHA') or define('BRUTEFORCE_CAPTCHA', 3);
defined('BRUTEFORCE_LOCKDOWN') or define('BRUTEFORCE_LOCKDOWN', 6);
diff --git a/app/functions.php b/app/functions.php
index fe6e6757..b9b30834 100644
--- a/app/functions.php
+++ b/app/functions.php
@@ -31,13 +31,3 @@ function n($value)
{
return Translator::getInstance()->number($value);
}
-
-/**
- * Translate a date
- *
- * @return string
- */
-function dt($format, $timestamp)
-{
- return Translator::getInstance()->datetime($format, $timestamp);
-}
diff --git a/app/routes.php b/app/routes.php
deleted file mode 100644
index 159e8f6e..00000000
--- a/app/routes.php
+++ /dev/null
@@ -1,117 +0,0 @@
-<?php
-
-// Dashboard
-$container['router']->addRoute('dashboard', 'app', 'index');
-$container['router']->addRoute('dashboard/:user_id', 'app', 'index', array('user_id'));
-$container['router']->addRoute('dashboard/:user_id/projects', 'app', 'projects', array('user_id'));
-$container['router']->addRoute('dashboard/:user_id/tasks', 'app', 'tasks', array('user_id'));
-$container['router']->addRoute('dashboard/:user_id/subtasks', 'app', 'subtasks', array('user_id'));
-$container['router']->addRoute('dashboard/:user_id/calendar', 'app', 'calendar', array('user_id'));
-$container['router']->addRoute('dashboard/:user_id/activity', 'app', 'activity', array('user_id'));
-
-// Search routes
-$container['router']->addRoute('search', 'search', 'index');
-$container['router']->addRoute('search/:search', 'search', 'index', array('search'));
-
-// Project routes
-$container['router']->addRoute('projects', 'project', 'index');
-$container['router']->addRoute('project/create', 'project', 'create');
-$container['router']->addRoute('project/create/:private', 'project', 'create', array('private'));
-$container['router']->addRoute('project/:project_id', 'project', 'show', array('project_id'));
-$container['router']->addRoute('p/:project_id', 'project', 'show', array('project_id'));
-$container['router']->addRoute('project/:project_id/share', 'project', 'share', array('project_id'));
-$container['router']->addRoute('project/:project_id/edit', 'project', 'edit', array('project_id'));
-$container['router']->addRoute('project/:project_id/integration', 'project', 'integration', array('project_id'));
-$container['router']->addRoute('project/:project_id/users', 'project', 'users', array('project_id'));
-$container['router']->addRoute('project/:project_id/duplicate', 'project', 'duplicate', array('project_id'));
-$container['router']->addRoute('project/:project_id/remove', 'project', 'remove', array('project_id'));
-$container['router']->addRoute('project/:project_id/disable', 'project', 'disable', array('project_id'));
-$container['router']->addRoute('project/:project_id/enable', 'project', 'enable', array('project_id'));
-
-// Action routes
-$container['router']->addRoute('project/:project_id/actions', 'action', 'index', array('project_id'));
-$container['router']->addRoute('project/:project_id/action/:action_id/confirm', 'action', 'confirm', array('project_id', 'action_id'));
-
-// Column routes
-$container['router']->addRoute('project/:project_id/columns', 'column', 'index', array('project_id'));
-$container['router']->addRoute('project/:project_id/column/:column_id/edit', 'column', 'edit', array('project_id', 'column_id'));
-$container['router']->addRoute('project/:project_id/column/:column_id/confirm', 'column', 'confirm', array('project_id', 'column_id'));
-$container['router']->addRoute('project/:project_id/column/:column_id/move/:direction', 'column', 'move', array('project_id', 'column_id', 'direction'));
-
-// Swimlane routes
-$container['router']->addRoute('project/:project_id/swimlanes', 'swimlane', 'index', array('project_id'));
-$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/edit', 'swimlane', 'edit', array('project_id', 'swimlane_id'));
-$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/confirm', 'swimlane', 'confirm', array('project_id', 'swimlane_id'));
-$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/disable', 'swimlane', 'disable', array('project_id', 'swimlane_id'));
-$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/enable', 'swimlane', 'enable', array('project_id', 'swimlane_id'));
-$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/up', 'swimlane', 'moveup', array('project_id', 'swimlane_id'));
-$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/down', 'swimlane', 'movedown', array('project_id', 'swimlane_id'));
-
-// Category routes
-$container['router']->addRoute('project/:project_id/categories', 'category', 'index', array('project_id'));
-$container['router']->addRoute('project/:project_id/category/:category_id/edit', 'category', 'edit', array('project_id', 'category_id'));
-$container['router']->addRoute('project/:project_id/category/:category_id/confirm', 'category', 'confirm', array('project_id', 'category_id'));
-
-// Task routes
-$container['router']->addRoute('project/:project_id/task/:task_id', 'task', 'show', array('project_id', 'task_id'));
-$container['router']->addRoute('t/:task_id', 'task', 'show', array('task_id'));
-$container['router']->addRoute('public/task/:task_id/:token', 'task', 'readonly', array('task_id', 'token'));
-
-$container['router']->addRoute('project/:project_id/task/:task_id/activity', 'activity', 'task', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/screenshot', 'file', 'screenshot', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/upload', 'file', 'create', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/comment', 'comment', 'create', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/link', 'tasklink', 'create', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/transitions', 'task', 'transitions', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/analytics', 'task', 'analytics', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/remove', 'task', 'remove', array('project_id', 'task_id'));
-
-$container['router']->addRoute('project/:project_id/task/:task_id/edit', 'taskmodification', 'edit', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/description', 'taskmodification', 'description', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/recurrence', 'taskmodification', 'recurrence', array('project_id', 'task_id'));
-
-$container['router']->addRoute('project/:project_id/task/:task_id/close', 'taskstatus', 'close', array('task_id', 'project_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/open', 'taskstatus', 'open', array('task_id', 'project_id'));
-
-$container['router']->addRoute('project/:project_id/task/:task_id/duplicate', 'taskduplication', 'duplicate', array('task_id', 'project_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/copy', 'taskduplication', 'copy', array('task_id', 'project_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/copy/:dst_project_id', 'taskduplication', 'copy', array('task_id', 'project_id', 'dst_project_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/move', 'taskduplication', 'move', array('task_id', 'project_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/move/:dst_project_id', 'taskduplication', 'move', array('task_id', 'project_id', 'dst_project_id'));
-
-// Board routes
-$container['router']->addRoute('board/:project_id', 'board', 'show', array('project_id'));
-$container['router']->addRoute('b/:project_id', 'board', 'show', array('project_id'));
-$container['router']->addRoute('public/board/:token', 'board', 'readonly', array('token'));
-
-// Calendar routes
-$container['router']->addRoute('calendar/:project_id', 'calendar', 'show', array('project_id'));
-$container['router']->addRoute('c/:project_id', 'calendar', 'show', array('project_id'));
-
-// Listing routes
-$container['router']->addRoute('list/:project_id', 'listing', 'show', array('project_id'));
-$container['router']->addRoute('l/:project_id', 'listing', 'show', array('project_id'));
-
-// Gantt routes
-$container['router']->addRoute('gantt/:project_id', 'gantt', 'project', array('project_id'));
-$container['router']->addRoute('gantt/:project_id/sort/:sorting', 'gantt', 'project', array('project_id', 'sorting'));
-
-// Subtask routes
-$container['router']->addRoute('project/:project_id/task/:task_id/subtask/create', 'subtask', 'create', array('project_id', 'task_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/remove', 'subtask', 'confirm', array('project_id', 'task_id', 'subtask_id'));
-$container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/edit', 'subtask', 'edit', array('project_id', 'task_id', 'subtask_id'));
-
-// Feed routes
-$container['router']->addRoute('feed/project/:token', 'feed', 'project', array('token'));
-$container['router']->addRoute('feed/user/:token', 'feed', 'user', array('token'));
-
-// Ical routes
-$container['router']->addRoute('ical/project/:token', 'ical', 'project', array('token'));
-$container['router']->addRoute('ical/user/:token', 'ical', 'user', array('token'));
-
-// Auth routes
-$container['router']->addRoute('oauth/google', 'oauth', 'google');
-$container['router']->addRoute('oauth/github', 'oauth', 'github');
-$container['router']->addRoute('oauth/gitlab', 'oauth', 'gitlab');
-$container['router']->addRoute('login', 'auth', 'login');
-$container['router']->addRoute('logout', 'auth', 'logout');
diff --git a/assets/css/app.css b/assets/css/app.css
index 4c106618..fa17ce2f 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -15,7 +15,7 @@
* Docs & License: http://fullcalendar.io/
* (c) 2015 Adam Shaw
*/.fc{direction:ltr;text-align:left}.fc-rtl{text-align:right}body .fc{font-size:1em}.fc-unthemed .fc-divider,.fc-unthemed .fc-popover,.fc-unthemed .fc-row,.fc-unthemed tbody,.fc-unthemed td,.fc-unthemed th,.fc-unthemed thead{border-color:#ddd}.fc-unthemed .fc-popover{background-color:#fff}.fc-unthemed .fc-divider,.fc-unthemed .fc-popover .fc-header{background:#eee}.fc-unthemed .fc-popover .fc-header .fc-close{color:#666}.fc-unthemed .fc-today{background:#fcf8e3}.fc-highlight{background:#bce8f1;opacity:.3;filter:alpha(opacity=30)}.fc-bgevent{background:#8fdf82;opacity:.3;filter:alpha(opacity=30)}.fc-nonbusiness{background:#d7d7d7}.fc-icon{display:inline-block;width:1em;height:1em;line-height:1em;font-size:1em;text-align:center;overflow:hidden;font-family:"Courier New",Courier,monospace;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.fc-icon:after{position:relative;margin:0 -1em}.fc-icon-left-single-arrow:after{content:"\02039";font-weight:700;font-size:200%;top:-7%;left:3%}.fc-icon-right-single-arrow:after{content:"\0203A";font-weight:700;font-size:200%;top:-7%;left:-3%}.fc-icon-left-double-arrow:after{content:"\000AB";font-size:160%;top:-7%}.fc-icon-right-double-arrow:after{content:"\000BB";font-size:160%;top:-7%}.fc-icon-left-triangle:after{content:"\25C4";font-size:125%;top:3%;left:-2%}.fc-icon-right-triangle:after{content:"\25BA";font-size:125%;top:3%;left:2%}.fc-icon-down-triangle:after{content:"\25BC";font-size:125%;top:2%}.fc-icon-x:after{content:"\000D7";font-size:200%;top:6%}.fc button{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;margin:0;height:2.1em;padding:0 .6em;font-size:1em;white-space:nowrap;cursor:pointer}.fc button::-moz-focus-inner{margin:0;padding:0}.fc-state-default{border:1px solid}.fc-state-default.fc-corner-left{border-top-left-radius:4px;border-bottom-left-radius:4px}.fc-state-default.fc-corner-right{border-top-right-radius:4px;border-bottom-right-radius:4px}.fc button .fc-icon{position:relative;top:-.05em;margin:0 .2em;vertical-align:middle}.fc-state-default{background-color:#f5f5f5;background-image:-moz-linear-gradient(top,#fff,#e6e6e6);background-image:-webkit-gradient(linear,0 0,0 100%,from(#fff),to(#e6e6e6));background-image:-webkit-linear-gradient(top,#fff,#e6e6e6);background-image:-o-linear-gradient(top,#fff,#e6e6e6);background-image:linear-gradient(to bottom,#fff,#e6e6e6);background-repeat:repeat-x;border-color:#e6e6e6 #e6e6e6 #bfbfbf;border-color:rgba(0,0,0,.1) rgba(0,0,0,.1) rgba(0,0,0,.25);color:#333;text-shadow:0 1px 1px rgba(255,255,255,.75);box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 1px 2px rgba(0,0,0,.05)}.fc-state-active,.fc-state-disabled,.fc-state-down,.fc-state-hover{color:#333;background-color:#e6e6e6}.fc-state-hover{color:#333;text-decoration:none;background-position:0 -15px;-webkit-transition:background-position .1s linear;-moz-transition:background-position .1s linear;-o-transition:background-position .1s linear;transition:background-position .1s linear}.fc-state-active,.fc-state-down{background-color:#ccc;background-image:none;box-shadow:inset 0 2px 4px rgba(0,0,0,.15),0 1px 2px rgba(0,0,0,.05)}.fc-state-disabled{cursor:default;background-image:none;opacity:.65;filter:alpha(opacity=65);box-shadow:none}.fc-button-group{display:inline-block}.fc .fc-button-group>*{float:left;margin:0 0 0 -1px}.fc .fc-button-group>:first-child{margin-left:0}.fc-popover{position:absolute;box-shadow:0 2px 6px rgba(0,0,0,.15)}.fc-popover .fc-header{padding:2px 4px}.fc-popover .fc-header .fc-title{margin:0 2px}.fc-popover .fc-header .fc-close{cursor:pointer}.fc-ltr .fc-popover .fc-header .fc-title,.fc-rtl .fc-popover .fc-header .fc-close{float:left}.fc-ltr .fc-popover .fc-header .fc-close,.fc-rtl .fc-popover .fc-header .fc-title{float:right}.fc-unthemed .fc-popover{border-width:1px;border-style:solid}.fc-unthemed .fc-popover .fc-header .fc-close{font-size:.9em;margin-top:2px}.fc-popover>.ui-widget-header+.ui-widget-content{border-top:0}.fc-divider{border-style:solid;border-width:1px}hr.fc-divider{height:0;margin:0;padding:0 0 2px;border-width:1px 0}.fc-clear{clear:both}.fc-bg,.fc-bgevent-skeleton,.fc-helper-skeleton,.fc-highlight-skeleton{position:absolute;top:0;left:0;right:0}.fc-bg{bottom:0}.fc-bg table{height:100%}.fc table{width:100%;table-layout:fixed;border-collapse:collapse;border-spacing:0;font-size:1em}.fc th{text-align:center}.fc td,.fc th{border-style:solid;border-width:1px;padding:0;vertical-align:top}.fc td.fc-today{border-style:double}.fc .fc-row{border-style:solid;border-width:0}.fc-row table{border-left:0 hidden transparent;border-right:0 hidden transparent;border-bottom:0 hidden transparent}.fc-row:first-child table{border-top:0 hidden transparent}.fc-row{position:relative}.fc-row .fc-bg{z-index:1}.fc-row .fc-bgevent-skeleton,.fc-row .fc-highlight-skeleton{bottom:0}.fc-row .fc-bgevent-skeleton table,.fc-row .fc-highlight-skeleton table{height:100%}.fc-row .fc-bgevent-skeleton td,.fc-row .fc-highlight-skeleton td{border-color:transparent}.fc-row .fc-bgevent-skeleton{z-index:2}.fc-row .fc-highlight-skeleton{z-index:3}.fc-row .fc-content-skeleton{position:relative;z-index:4;padding-bottom:2px}.fc-row .fc-helper-skeleton{z-index:5}.fc-row .fc-content-skeleton td,.fc-row .fc-helper-skeleton td{background:0 0;border-color:transparent;border-bottom:0}.fc-row .fc-content-skeleton tbody td,.fc-row .fc-helper-skeleton tbody td{border-top:0}.fc-scroller{overflow-y:scroll;overflow-x:hidden}.fc-scroller>*{position:relative;width:100%;overflow:hidden}.fc-event{position:relative;display:block;font-size:.85em;line-height:1.3;border-radius:3px;border:1px solid #3a87ad;background-color:#3a87ad;font-weight:400}.fc-event,.fc-event:hover,.ui-widget .fc-event{color:#fff;text-decoration:none}.fc-event.fc-draggable,.fc-event[href]{cursor:pointer}.fc-not-allowed,.fc-not-allowed .fc-event{cursor:not-allowed}.fc-event .fc-bg{z-index:1;background:#fff;opacity:.25;filter:alpha(opacity=25)}.fc-event .fc-content{position:relative;z-index:2}.fc-event .fc-resizer{position:absolute;z-index:3}.fc-ltr .fc-h-event.fc-not-start,.fc-rtl .fc-h-event.fc-not-end{margin-left:0;border-left-width:0;padding-left:1px;border-top-left-radius:0;border-bottom-left-radius:0}.fc-ltr .fc-h-event.fc-not-end,.fc-rtl .fc-h-event.fc-not-start{margin-right:0;border-right-width:0;padding-right:1px;border-top-right-radius:0;border-bottom-right-radius:0}.fc-h-event .fc-resizer{top:-1px;bottom:-1px;left:-1px;right:-1px;width:5px}.fc-ltr .fc-h-event .fc-start-resizer,.fc-ltr .fc-h-event .fc-start-resizer:after,.fc-ltr .fc-h-event .fc-start-resizer:before,.fc-rtl .fc-h-event .fc-end-resizer,.fc-rtl .fc-h-event .fc-end-resizer:after,.fc-rtl .fc-h-event .fc-end-resizer:before{right:auto;cursor:w-resize}.fc-ltr .fc-h-event .fc-end-resizer,.fc-ltr .fc-h-event .fc-end-resizer:after,.fc-ltr .fc-h-event .fc-end-resizer:before,.fc-rtl .fc-h-event .fc-start-resizer,.fc-rtl .fc-h-event .fc-start-resizer:after,.fc-rtl .fc-h-event .fc-start-resizer:before{left:auto;cursor:e-resize}.fc-day-grid-event{margin:1px 2px 0;padding:0 1px}.fc-day-grid-event .fc-content{white-space:nowrap;overflow:hidden}.fc-day-grid-event .fc-time{font-weight:700}.fc-day-grid-event .fc-resizer{left:-3px;right:-3px;width:7px}a.fc-more{margin:1px 3px;font-size:.85em;cursor:pointer;text-decoration:none}a.fc-more:hover{text-decoration:underline}.fc-limited{display:none}.fc-day-grid .fc-row{z-index:1}.fc-more-popover{z-index:2;width:220px}.fc-more-popover .fc-event-container{padding:10px}.fc-toolbar{text-align:center;margin-bottom:1em}.fc-toolbar .fc-left{float:left}.fc-toolbar .fc-right{float:right}.fc-toolbar .fc-center{display:inline-block}.fc .fc-toolbar>*>*{float:left;margin-left:.75em}.fc .fc-toolbar>*>:first-child{margin-left:0}.fc-toolbar h2{margin:0}.fc-toolbar button{position:relative}.fc-toolbar .fc-state-hover,.fc-toolbar .ui-state-hover{z-index:2}.fc-toolbar .fc-state-down{z-index:3}.fc-toolbar .fc-state-active,.fc-toolbar .ui-state-active{z-index:4}.fc-toolbar button:focus{z-index:5}.fc-view-container *,.fc-view-container :after,.fc-view-container :before{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.fc-view,.fc-view>table{position:relative;z-index:1}.fc-basicDay-view .fc-content-skeleton,.fc-basicWeek-view .fc-content-skeleton{padding-top:1px;padding-bottom:1em}.fc-basic-view .fc-body .fc-row{min-height:4em}.fc-row.fc-rigid{overflow:hidden}.fc-row.fc-rigid .fc-content-skeleton{position:absolute;top:0;left:0;right:0}.fc-basic-view .fc-day-number,.fc-basic-view .fc-week-number{padding:0 2px}.fc-basic-view td.fc-day-number,.fc-basic-view td.fc-week-number span{padding-top:2px;padding-bottom:2px}.fc-basic-view .fc-week-number{text-align:center}.fc-basic-view .fc-week-number span{display:inline-block;min-width:1.25em}.fc-ltr .fc-basic-view .fc-day-number{text-align:right}.fc-rtl .fc-basic-view .fc-day-number{text-align:left}.fc-day-number.fc-other-month{opacity:.3;filter:alpha(opacity=30)}.fc-agenda-view .fc-day-grid{position:relative;z-index:2}.fc-agenda-view .fc-day-grid .fc-row{min-height:3em}.fc-agenda-view .fc-day-grid .fc-row .fc-content-skeleton{padding-top:1px;padding-bottom:1em}.fc .fc-axis{vertical-align:middle;padding:0 4px;white-space:nowrap}.fc-ltr .fc-axis{text-align:right}.fc-rtl .fc-axis{text-align:left}.ui-widget td.fc-axis{font-weight:400}.fc-time-grid,.fc-time-grid-container{position:relative;z-index:1}.fc-time-grid{min-height:100%}.fc-time-grid table{border:0 hidden transparent}.fc-time-grid>.fc-bg{z-index:1}.fc-time-grid .fc-slats,.fc-time-grid>hr{position:relative;z-index:2}.fc-time-grid .fc-bgevent-skeleton,.fc-time-grid .fc-content-skeleton{position:absolute;top:0;left:0;right:0}.fc-time-grid .fc-bgevent-skeleton{z-index:3}.fc-time-grid .fc-highlight-skeleton{z-index:4}.fc-time-grid .fc-content-skeleton{z-index:5}.fc-time-grid .fc-helper-skeleton{z-index:6}.fc-time-grid .fc-slats td{height:1.5em;border-bottom:0}.fc-time-grid .fc-slats .fc-minor td{border-top-style:dotted}.fc-time-grid .fc-slats .ui-widget-content{background:0 0}.fc-time-grid .fc-highlight-container{position:relative}.fc-time-grid .fc-highlight{position:absolute;left:0;right:0}.fc-time-grid .fc-bgevent-container,.fc-time-grid .fc-event-container{position:relative}.fc-ltr .fc-time-grid .fc-event-container{margin:0 2.5% 0 2px}.fc-rtl .fc-time-grid .fc-event-container{margin:0 2px 0 2.5%}.fc-time-grid .fc-bgevent,.fc-time-grid .fc-event{position:absolute;z-index:1}.fc-time-grid .fc-bgevent{left:0;right:0}.fc-v-event.fc-not-start{border-top-width:0;padding-top:1px;border-top-left-radius:0;border-top-right-radius:0}.fc-v-event.fc-not-end{border-bottom-width:0;padding-bottom:1px;border-bottom-left-radius:0;border-bottom-right-radius:0}.fc-time-grid-event{overflow:hidden}.fc-time-grid-event .fc-time,.fc-time-grid-event .fc-title{padding:0 1px}.fc-time-grid-event .fc-time{font-size:.85em;white-space:nowrap}.fc-time-grid-event.fc-short .fc-content{white-space:nowrap}.fc-time-grid-event.fc-short .fc-time,.fc-time-grid-event.fc-short .fc-title{display:inline-block;vertical-align:top}.fc-time-grid-event.fc-short .fc-time span{display:none}.fc-time-grid-event.fc-short .fc-time:before{content:attr(data-start)}.fc-time-grid-event.fc-short .fc-time:after{content:"\000A0-\000A0"}.fc-time-grid-event.fc-short .fc-title{font-size:.85em;padding:0}.fc-time-grid-event .fc-resizer{left:0;right:0;bottom:0;height:8px;overflow:hidden;line-height:8px;font-size:11px;font-family:monospace;text-align:center;cursor:s-resize}.fc-time-grid-event .fc-resizer:after{content:"="}/*!
- * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
- */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.4.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.4.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.4.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.4.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.4.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.4.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}
-.c3 svg{font:10px sans-serif}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}li,ul,ol,table,tr,td,th,p,blockquote,body{margin:0;padding:0;font-size:100%}body{margin-left:10px;margin-right:10px;padding-bottom:20px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{list-style-type:none;margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,0.1);border-bottom:1px solid rgba(255,255,255,0.3)}.chosen-select{min-height:27px}.avatar{float:left;margin-right:10px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36c}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a{color:#36c;border:0}a:focus{outline:0;color:#df5353;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}h1,h2,h3{font-weight:normal;color:#333}h2{font-size:1.3em;margin-bottom:10px}h3{margin-top:10px;font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}th,td{border:1px solid #eee;padding-top:.5em;padding-bottom:.5em;padding-left:3px;padding-right:3px}td{vertical-align:top}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a{text-decoration:none;color:#333}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-fixed th{overflow:hidden}.table-fixed td{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-stripped tr:nth-child(odd) td{background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70{width:70%}form{margin-bottom:20px}label{cursor:pointer;display:block;margin-top:10px}input[type="number"],input[type="date"],input[type="email"],input[type="password"],input[type="text"]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type="number"]:focus,input[type="date"]:focus,input[type="email"]:focus,input[type="password"]:focus,input[type="text"]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,0.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,0.6)}input.form-numeric,input[type="number"]{width:70px}textarea{border:1px solid #ccc;width:400px;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:bold}.form-errors{color:#b94a48;list-style-type:none}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:0}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0;margin-right:15px}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-datetime,input.form-date{width:150px}input.form-input-large{width:400px}.form-row{margin-top:10px;margin-bottom:20px}.form-column{float:left;margin-right:3%;max-width:47%}.form-column ul{margin-top:15px}.form-login{width:350px;margin:0 auto;margin-top:8%}.form-column li,.form-login li{margin-left:25px;line-height:25px}label+.form-tabs{margin-top:10px}.form-tabs{width:100%;max-width:800px}ul.form-tabs-nav{margin-bottom:8px;margin-top:0}.form-tabs-nav li{margin-left:0;display:inline}.form-tab{margin-right:20px}.form-tab a{color:#ccc;font-weight:bold;text-decoration:none}.form-tab a:focus,.form-tab a:hover{color:#000}.form-tab-selected a{color:#333}.preview-area{border:1px dashed #000;padding-top:5px;padding-left:5px;padding-right:5px;margin-bottom:5px;display:none;overflow:auto}.btn{-webkit-appearance:none;appearance:none;display:inline-block;color:#333;border:1px solid #ccc;background:#efefef;padding:5px;padding-left:15px;padding-right:15px;font-size:.9em;cursor:pointer;border-radius:2px}a.btn{text-decoration:none;font-weight:bold}.btn-small{padding:2px;padding-left:5px;padding-right:5px}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}a.btn-red:hover,.btn-red:hover,.btn-red:focus{color:#fff;background:#c53727}a.btn-blue,.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}a.btn-blue:hover,.btn-blue:hover,a.btn-blue:focus,.btn-blue:focus{border-color:#2f5bb7;background:#357ae8}.btn-blue:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}#main .alert,.page .alert{margin-top:10px}.alert{padding:8px 35px 8px 14px;margin-bottom:10px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert li{margin-left:25px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;overflow:hidden;position:absolute}.tooltip-arrow.top{top:-10px}.tooltip-arrow.bottom{bottom:-10px}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.tooltip-arrow.bottom:after{top:-10px}.tooltip-arrow.top:after{bottom:-10px}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:550px}.ui-tooltip-content .markdown p{margin-bottom:0}.tooltip .fa-info-circle{color:#999;font-size:.95em}.ui-tooltip ul{margin-left:20px}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#777;text-decoration:none}nav .active a{color:#333;font-weight:bold}.username a{color:#000}.username a:hover{color:#df5353;text-decoration:underline}.logo{opacity:.3;color:#d40000}.logo span{color:#333}.logo:hover{opacity:.8}.logo:focus span,.logo:hover span{color:#d40000}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:bold;border-bottom:1px dotted #ccc}.page-header h2 a{color:#ddd}.page-header h2 a:focus,.page-header h2 a:hover{color:#333}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:10px;font-size:.95em}.menu-inline{margin-bottom:5px}@media only screen and (max-width:640px){.page-header-mobile li{display:block;margin-bottom:5px}}.public-board{margin-top:5px}.public-task{max-width:800px;margin:0 auto;margin-top:5px}#board-container{overflow-x:auto}#board{table-layout:fixed}#board th.board-column-header{width:240px}#board td{vertical-align:top}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:bold;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title{cursor:pointer}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36c;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:normal}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:hover,a.board-swimlane-toggle:focus{color:#000;text-decoration:none;border:0}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#df5353}.draggable-item{cursor:pointer;user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-board{position:relative;margin-bottom:4px;border:1px solid #000;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{box-shadow:2px 2px 3px rgba(0,0,0,0.2)}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-table a,.task-board a{color:#000;text-decoration:none;font-weight:bold}.task-table a:focus,.task-table a:hover,.task-board a:focus,.task-board a:hover{text-decoration:underline}.task-board-collapsed{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}a.task-board-collapsed-title{font-weight:normal}.task-board .dropdown{font-size:1.1em}.task-board-title{margin-top:5px;margin-bottom:5px;font-size:1.1em}.task-board-title a{font-weight:normal}.task-board-user{font-size:.8em}.task-board-current-user a{text-decoration:underline}.task-board-current-user a:focus,.task-board-current-user a:hover{text-decoration:none}a.task-board-nobody{font-weight:normal;font-style:italic;color:#444}.task-board-category-container{text-align:right}.task-board-category{font-weight:bold;font-size:.9em;color:#000;border:1px solid #555;padding:2px;padding-right:5px;padding-left:5px}.task-board-icons{text-align:right;margin-top:8px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1.0}.task-board-date{font-weight:bold;color:#000}span.task-board-date-overdue{color:#d90000;opacity:1.0}.task-score{font-weight:bold}.task-board .task-score{font-size:1.1em}.task-show-details .task-score{position:absolute;bottom:5px;right:5px;font-size:2em}.task-board-closed,.task-board-days{position:absolute;right:5px;top:5px;opacity:.5;font-size:.8em}.task-board-days:hover{opacity:1.0}.task-days-age{border:#666 1px solid;padding:1px 4px 1px 2px;border-top-left-radius:3px;border-bottom-left-radius:3px}.task-days-incolumn{border:#666 1px solid;border-left:0;margin-left:-5px;padding:1px 2px 1px 4px;border-top-right-radius:3px;border-bottom-right-radius:3px}.board-container-compact .task-board-days{display:none}.task-show-details{position:relative;border-radius:5px;padding-bottom:10px}.task-show-details h2{font-size:1.8em;margin:0;margin-bottom:25px;padding:0;padding-left:10px;padding-right:10px}.task-show-details li{margin-left:25px;list-style-type:circle}.task-show-section{margin-top:30px;margin-bottom:20px}.task-show-files a{font-weight:bold;text-decoration:none}.task-show-files li{margin-left:25px;list-style-type:square;line-height:25px}.task-show-file-actions{font-size:.75em}.task-show-file-actions:before{content:" ["}.task-show-file-actions:after{content:"]"}.task-show-file-actions a{color:#333}.task-show-description{border-left:4px solid #333;padding-left:20px}.task-show-description-textarea{width:99%;max-width:99%;height:300px}.task-file-viewer{position:relative}.task-file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.task-time-form{margin-top:10px;margin-bottom:25px;padding:3px}.task-link-closed{text-decoration:line-through}.task-show-images{list-style-type:none}.task-show-images li img{width:100%}.task-show-images li .img_container{width:250px;height:100px;overflow:hidden}.task-show-images li{padding:10px;overflow:auto;width:250px;min-height:120px;display:inline-block;vertical-align:top}.task-show-images li p{padding:5px;font-weight:bold}.task-show-images li:hover{background:#eee}.task-show-image-actions{margin-left:5px}.task-show-file-table{width:auto}.task-show-start-link{color:#000}.task-show-start-link:hover,.task-show-start-link:focus{color:red}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000;cursor:pointer}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,0.9)}.comment{margin-bottom:20px}.comment:hover{background:#f7f8e0}.comment-inner{border-left:4px solid #333;padding-bottom:10px;padding-left:20px;margin-left:20px;margin-right:10px}.comment-preview{border:2px solid #000;border-radius:3px;padding:10px}.comment-preview .comment-inner{border:0;padding:0;margin:0}.comment-title{margin-bottom:8px;padding-bottom:3px;border-bottom:1px dotted #aaa}.ui-tooltip .comment-title{font-size:80%}.ui-tooltip .comment-inner{padding-bottom:0}.comment-actions{font-size:.8em;padding:0;text-align:right}.comment-actions li{display:inline;padding-left:5px;padding-right:5px;border-right:1px dotted #000}.comment-actions li:last-child{padding-right:0;border:0}.comment-username{font-weight:bold}.comment-textarea{height:200px;width:80%;max-width:800px}.comment-sorting{font-size:.5em}span.comment-sorting a{color:#555;font-weight:normal;text-decoration:none}span.comment-sorting a:hover{color:#aaa}#comments .comment-textarea{height:80px;width:500px}.subtasks-table{font-size:.85em}.subtasks-table td{vertical-align:middle}.markdown{line-height:1.4em;font-size:1.0}.markdown h1{margin-top:5px;margin-bottom:10px;font-size:1.5em;font-weight:bold;text-decoration:underline}.markdown h2{font-size:1.2em;font-weight:bold;text-decoration:underline}.markdown h3{font-size:1.1em;text-decoration:underline}.markdown h4{font-size:1.1em;text-decoration:underline}.markdown p{margin-bottom:10px}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-bottom:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fefefe;overflow:auto}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.listing ul{margin-top:15px;margin-bottom:15px}.activity-event{margin-bottom:20px}.activity-datetime{color:#999;font-size:.85em}.activity-content{margin-top:10px;margin-left:20px;padding-left:20px;border-left:2px solid #666}.activity-title{font-weight:bold;color:#000}.activity-description{font-size:.9em;color:#aaa;padding-top:5px}.activity-description ul{margin-top:10px}.activity-description li{margin-left:40px;list-style-type:circle;color:#555}.activity-description .markdown{margin-top:10px;color:#555}.activity-changes{margin-top:10px;font-size:.85em}.activity-changes ul{margin-left:25px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:bold;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination{text-align:center}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;margin:0 0 0 -35%;left:50%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:85%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;position:relative;clear:both}.sidebar-content{margin-left:23%;width:76%;position:absolute}.sidebar{width:20%;float:left;padding:10px;padding-top:0;border:1px solid #ddd;background:#fdfdfd;border-radius:5px}.sidebar li{list-style-type:square;margin-left:30px;line-height:1.8em}.sidebar li.active a{color:#000;font-weight:bold;text-decoration:none}.sidebar li.active a:focus,.sidebar li.active a:hover{text-decoration:underline}.sidebar-collapsed .sidebar{width:10px;padding-bottom:0;float:none}.sidebar-collapsed .sidebar-content{margin:0;margin-top:15px;width:100%}.sidebar-collapse{text-align:right}.sidebar-collapse a,.sidebar-expand a{color:#333;text-decoration:none}.sidebar-collapse a:hover,.sidebar-expand a:hover{color:#df5353}@media only screen and (max-width:1024px){.sidebar{width:25%}.sidebar-content{margin-left:30%;width:70%}}@media only screen and (max-width:767px){.sidebar{width:95%;float:none}.sidebar-content{margin:0;margin-top:15px;width:100%}}@media only screen and (max-width:1080px){div.filter-dropdowns .filters{margin-left:0}div.filter-dropdowns{display:block;margin-top:5px}}@media only screen and (max-width:1024px){li.hide-tablet,.hide-tablet{display:none}body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type="submit"],.form-inline-group label{display:block}.form-inline-group input[type="submit"]{margin-top:20px}td>input[type="text"]{max-width:150px}.task-time-form label{display:block}.task-time-form input[type="submit"]{margin-top:10px;display:block}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type="number"],input[type="date"],input[type="email"],input[type="password"],input[type="text"]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;list-style:none;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,0.15)}.dropdown-submenu-open li{display:block;margin:0;padding:0;padding-left:10px;padding-right:10px;padding-top:8px;padding-bottom:8px;font-size:.85em;border-bottom:1px solid #f8f8f8}.dropdown-submenu-open li:last-child{border:0}.dropdown-submenu-open li:hover{background:#4078c0;color:#fff}.dropdown-submenu-open li:hover a{color:#fff}.dropdown-submenu-open a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}#screenshot-zone{position:relative;border:2px dashed #ccc;width:90%;height:250px;overflow:auto}#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center}#screenshot-zone.screenshot-pasted{border:2px solid #333}.toolbar{font-size:.9em;padding-top:5px}.views{display:inline-block;margin-right:10px;font-size:.9em}.views li{border:1px solid #eee;padding-left:8px;padding-right:8px;padding-top:5px;padding-bottom:5px;display:inline}.menu-inline li.active a,.views li.active a{font-weight:bold;color:#000;text-decoration:none}.views li:first-child{border-right:0;border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-left:0;border-top-right-radius:5px;border-bottom-right-radius:5px}.filters{display:inline-block;border:1px solid #eee;border-radius:5px;padding:5px;padding-right:10px;margin-left:8px}.filters ul{font-size:.8em}.page-header .filters ul{font-size:.9em}form.search{display:inline}div.search{margin-bottom:20px}.filter-dropdowns{font-size:.9em;display:inline-block}div.ganttview-hzheader-month,div.ganttview-hzheader-day,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series,div.ganttview-grid,div.ganttview-grid-row-cell{float:left}div.ganttview-hzheader-month,div.ganttview-hzheader-day{text-align:center}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:0}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#e5ecf9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:-0} \ No newline at end of file
+ */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.5.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}
+.c3 svg{font:10px sans-serif}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}li,ul,ol,table,tr,td,th,p,blockquote,body{margin:0;padding:0;font-size:100%}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{list-style-type:none;margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,0.1);border-bottom:1px solid rgba(255,255,255,0.3)}.chosen-select{min-height:27px}.avatar{float:left;margin-right:10px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36c}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}.smaller{font-size:.85em}a{color:#36c;border:0}a:focus{outline:0;color:#df5353;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}h1,h2,h3{font-weight:normal;color:#333}h2{font-size:1.3em;margin-bottom:10px}h3{margin-top:10px;font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}th,td{border:1px solid #eee;padding-top:.5em;padding-bottom:.5em;padding-left:3px;padding-right:3px}td{vertical-align:top}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a{text-decoration:none;color:#333}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-fixed th{overflow:hidden}.table-fixed td{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,0.55)}tr.draggable-item-selected td{border-top:0;border-bottom:0}tr.draggable-item-selected td:first-child{border-left:0}tr.draggable-item-selected td:last-child{border-right:0}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#fefff2}form{margin-bottom:20px}label{cursor:pointer;display:block;margin-top:10px}input[type="number"],input[type="date"],input[type="email"],input[type="password"],input[type="text"]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type="number"]:focus,input[type="date"]:focus,input[type="email"]:focus,input[type="password"]:focus,input[type="text"]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,0.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,0.6)}input.form-numeric,input[type="number"]{width:70px}textarea{border:1px solid #ccc;width:400px;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:bold}.form-errors{color:#b94a48;list-style-type:none}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:0}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0;margin-right:15px}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-datetime,input.form-date{width:150px}input.form-input-large{width:400px}.form-column{float:left;margin-right:3%;max-width:47%}.form-column ul{margin-top:15px}.form-clear{clear:both;padding-top:20px;padding-bottom:10px}.form-login{width:350px;margin:0 auto;margin-top:8%}.form-column li,.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:bold}label+.form-tabs{margin-top:10px}.form-tabs{width:100%;max-width:800px}ul.form-tabs-nav{margin-bottom:8px;margin-top:0}.form-tabs-nav li{margin-left:0;display:inline}.form-tab{margin-right:20px}.form-tab a{color:#ccc;font-weight:bold;text-decoration:none}.form-tab a:focus,.form-tab a:hover{color:#000}.form-tab-selected a{color:#333}.preview-area{border:1px dashed #000;padding-top:5px;padding-left:5px;padding-right:5px;margin-bottom:5px;display:none;overflow:auto}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{-webkit-appearance:none;appearance:none;display:inline-block;color:#333;border:1px solid #ccc;background:#efefef;padding:5px;padding-left:15px;padding-right:15px;font-size:.9em;cursor:pointer;border-radius:2px}a.btn{text-decoration:none;font-weight:bold}.btn-small{padding:2px;padding-left:5px;padding-right:5px}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}a.btn-red:hover,.btn-red:hover,.btn-red:focus{color:#fff;background:#c53727}a.btn-blue,.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}a.btn-blue:hover,.btn-blue:hover,a.btn-blue:focus,.btn-blue:focus{border-color:#2f5bb7;background:#357ae8}.btn-blue:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}#main .alert,.page .alert{margin-top:10px}.alert{padding:8px 35px 8px 14px;margin-bottom:10px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert li{margin-left:25px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;overflow:hidden;position:absolute}.tooltip-arrow.top{top:-10px}.tooltip-arrow.bottom{bottom:-10px}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.tooltip-arrow.bottom:after{top:-10px}.tooltip-arrow.top:after{bottom:-10px}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:550px}.ui-tooltip-content .markdown p{margin-bottom:0}.tooltip .fa-info-circle{color:#999;font-size:.95em}.ui-tooltip ul{margin-left:20px}.ui-tooltip dl{margin:-5px 0 0 0;padding:0}.ui-tooltip dt{margin-top:5px}.ui-tooltip dd{margin-left:0}.ui-tooltip .progress{display:inline-block;min-width:3em;text-align:right}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#777;text-decoration:none}nav .active a{color:#333;font-weight:bold}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:bold;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333;text-decoration:none}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:bold}.page-header li.active a:hover,.page-header li.active a:focus{text-decoration:underline}.menu-inline{margin-bottom:5px}@media only screen and (max-width:640px){.page-header-mobile li{display:block;margin-bottom:5px}}.public-board{margin-top:5px}.public-task{max-width:800px;margin:0 auto;margin-top:5px}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}#board td{vertical-align:top}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:bold;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36c;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:normal}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:hover,a.board-swimlane-toggle:focus{color:#000;text-decoration:none;border:0}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#df5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-board-saving-state{opacity:.3}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board{position:relative;margin-bottom:4px;border:1px solid #000;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-table a,.task-board a{color:#000;text-decoration:none;font-weight:bold}.task-table a:focus,.task-table a:hover,.task-board a:focus,.task-board a:hover{text-decoration:underline}.task-board-collapsed{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}a.task-board-collapsed-title{font-weight:normal}.task-board .dropdown{font-size:1.1em}.task-board-title{margin-top:5px;margin-bottom:5px;font-size:1.1em}.task-board-title a{font-weight:normal}.task-board-user{font-size:.8em}.task-board-current-user a{text-decoration:underline}.task-board-current-user a:focus,.task-board-current-user a:hover{text-decoration:none}a.task-board-nobody{font-weight:normal;font-style:italic;color:#444}.task-board-category-container{text-align:right}.task-board-category{font-weight:bold;font-size:.9em;color:#000;border:1px solid #555;padding:2px;padding-right:5px;padding-left:5px}.task-board-icons{text-align:right;margin-top:8px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1.0}.task-board-date{font-weight:bold;color:#000}span.task-board-date-overdue{color:#d90000;opacity:1.0}.task-board .task-score{font-weight:bold;font-size:1.1em}.task-board-closed,.task-board-days{position:absolute;right:5px;top:5px;opacity:.5;font-size:.8em}.task-board-days:hover{opacity:1.0}.task-days-age{border:#666 1px solid;padding:1px 4px 1px 2px;border-top-left-radius:3px;border-bottom-left-radius:3px}.task-days-incolumn{border:#666 1px solid;border-left:0;margin-left:-5px;padding:1px 2px 1px 4px;border-top-right-radius:3px;border-bottom-right-radius:3px}.board-container-compact .task-board-days{display:none}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px;display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-description{border-left:4px solid #333;padding-left:20px}.task-show-description-textarea{width:99%;max-width:99%;height:300px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000;cursor:pointer}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,0.9)}.assign-me{font-size:.8em;vertical-align:bottom}.comment{margin-bottom:20px}.comment:hover{background:#f7f8e0}.comment-inner{border-left:4px solid #333;padding-bottom:10px;padding-left:20px;margin-left:20px;margin-right:10px}.comment-preview{border:2px solid #000;border-radius:3px;padding:10px}.comment-preview .comment-inner{border:0;padding:0;margin:0}.comment-title{margin-bottom:8px;padding-bottom:3px;border-bottom:1px dotted #aaa}.ui-tooltip .comment-title{font-size:80%}.ui-tooltip .comment-inner{padding-bottom:0}.comment-actions{font-size:.8em;padding:0;text-align:right}.comment-actions li{display:inline;padding-left:5px;padding-right:5px;border-right:1px dotted #000}.comment-actions li:last-child{padding-right:0;border:0}.comment-username{font-weight:bold}.comment-textarea{height:200px;width:80%;max-width:800px}.comment-sorting{font-size:.5em}span.comment-sorting a{color:#555;font-weight:normal;text-decoration:none}span.comment-sorting a:hover{color:#aaa}#comments .comment-textarea{height:80px;width:500px}.subtasks-table{font-size:.85em}.subtasks-table td{vertical-align:middle}.markdown{line-height:1.4em;font-size:1.0}.markdown h1{margin-top:5px;margin-bottom:10px;font-size:1.5em;font-weight:bold;text-decoration:underline}.markdown h2{font-size:1.2em;font-weight:bold;text-decoration:underline}.markdown h3{font-size:1.1em;text-decoration:underline}.markdown h4{font-size:1.1em;text-decoration:underline}.markdown p{margin-bottom:10px}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-bottom:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:bold;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.listing ul{margin-top:15px;margin-bottom:15px}.activity-event{margin-bottom:20px}.activity-datetime{color:#999;font-size:.85em}.activity-content{margin-top:10px;margin-left:20px;padding-left:20px;border-left:2px solid #666}.activity-title{font-weight:bold;color:#000}.activity-description{font-size:.9em;color:#aaa;padding-top:5px}.activity-description ul{margin-top:10px}.activity-description li{margin-left:40px;list-style-type:circle;color:#555}.activity-description .markdown{margin-top:10px;color:#555}.activity-changes{margin-top:10px;font-size:.85em}.activity-changes ul{margin-left:25px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:bold;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination{text-align:center}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;margin:0 0 0 -35%;left:50%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:85%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar a{text-decoration:none}.sidebar li{list-style-type:none;line-height:35px;border-bottom:1px dotted #efefef}.sidebar li:hover{border-left:5px solid #555;padding-left:8px}.sidebar li.active{border-left:5px solid #333;padding-left:8px}.sidebar li.active a{color:#333;font-weight:bold}.sidebar li.active a:focus,.sidebar li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type="submit"],.form-inline-group label{display:block}.form-inline-group input[type="submit"]{margin-top:20px}td>input[type="text"]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type="number"],input[type="date"],input[type="email"],input[type="password"],input[type="text"]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;list-style:none;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,0.15)}.textarea-dropdown li,.dropdown-submenu-open li{display:block;margin:0;padding:0;padding-left:10px;padding-right:10px;padding-top:8px;padding-bottom:8px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.textarea-dropdown li:last-child,.dropdown-submenu-open li:last-child{border:0}.textarea-dropdown .active,.textarea-dropdown li:hover,.dropdown-submenu-open li:not(.no-hover):hover{background:#4078c0;color:#fff}.textarea-dropdown .active a,.textarea-dropdown li:hover a,.dropdown-submenu-open li:hover a{color:#fff}.textarea-dropdown a,.dropdown-submenu-open a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-text,.dropdown-menu-link-icon{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{list-style:none;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,0.15)}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:bold;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.project-header .filter-box{margin:0}.filter-box form{margin:0}.filter-box input[type="text"]{margin:0;font-size:16px;height:26px;border-color:#dedede;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type="text"]:focus{color:#000;border-color:rgba(82,168,236,0.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,0.6)}.filter-box div.dropdown{display:inline-block;font-size:16px;border:1px solid #dedede;border-left:0;margin:0;padding:0;padding-left:5px;padding-right:8px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-hzheader-month,div.ganttview-hzheader-day,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series,div.ganttview-grid,div.ganttview-grid-row-cell{float:left}div.ganttview-hzheader-month,div.ganttview-hzheader-day{text-align:center}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:0}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#e5ecf9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:-0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{text-align:center;margin-right:80px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,0.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail img:hover{opacity:.5}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{border:1px solid #eee;padding-left:8px;padding-right:8px;padding-top:5px;padding-bottom:5px;display:inline}.menu-inline li.active a,.views li.active a{font-weight:bold;color:#000;text-decoration:none}.views li:first-child{border-right:0;border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-left:0;border-top-right-radius:5px;border-bottom-right-radius:5px} \ No newline at end of file
diff --git a/assets/css/print.css b/assets/css/print.css
index ccdaf038..7cfd4420 100644
--- a/assets/css/print.css
+++ b/assets/css/print.css
@@ -15,7 +15,7 @@
* Docs & License: http://fullcalendar.io/
* (c) 2015 Adam Shaw
*/.fc{direction:ltr;text-align:left}.fc-rtl{text-align:right}body .fc{font-size:1em}.fc-unthemed .fc-divider,.fc-unthemed .fc-popover,.fc-unthemed .fc-row,.fc-unthemed tbody,.fc-unthemed td,.fc-unthemed th,.fc-unthemed thead{border-color:#ddd}.fc-unthemed .fc-popover{background-color:#fff}.fc-unthemed .fc-divider,.fc-unthemed .fc-popover .fc-header{background:#eee}.fc-unthemed .fc-popover .fc-header .fc-close{color:#666}.fc-unthemed .fc-today{background:#fcf8e3}.fc-highlight{background:#bce8f1;opacity:.3;filter:alpha(opacity=30)}.fc-bgevent{background:#8fdf82;opacity:.3;filter:alpha(opacity=30)}.fc-nonbusiness{background:#d7d7d7}.fc-icon{display:inline-block;width:1em;height:1em;line-height:1em;font-size:1em;text-align:center;overflow:hidden;font-family:"Courier New",Courier,monospace;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.fc-icon:after{position:relative;margin:0 -1em}.fc-icon-left-single-arrow:after{content:"\02039";font-weight:700;font-size:200%;top:-7%;left:3%}.fc-icon-right-single-arrow:after{content:"\0203A";font-weight:700;font-size:200%;top:-7%;left:-3%}.fc-icon-left-double-arrow:after{content:"\000AB";font-size:160%;top:-7%}.fc-icon-right-double-arrow:after{content:"\000BB";font-size:160%;top:-7%}.fc-icon-left-triangle:after{content:"\25C4";font-size:125%;top:3%;left:-2%}.fc-icon-right-triangle:after{content:"\25BA";font-size:125%;top:3%;left:2%}.fc-icon-down-triangle:after{content:"\25BC";font-size:125%;top:2%}.fc-icon-x:after{content:"\000D7";font-size:200%;top:6%}.fc button{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;margin:0;height:2.1em;padding:0 .6em;font-size:1em;white-space:nowrap;cursor:pointer}.fc button::-moz-focus-inner{margin:0;padding:0}.fc-state-default{border:1px solid}.fc-state-default.fc-corner-left{border-top-left-radius:4px;border-bottom-left-radius:4px}.fc-state-default.fc-corner-right{border-top-right-radius:4px;border-bottom-right-radius:4px}.fc button .fc-icon{position:relative;top:-.05em;margin:0 .2em;vertical-align:middle}.fc-state-default{background-color:#f5f5f5;background-image:-moz-linear-gradient(top,#fff,#e6e6e6);background-image:-webkit-gradient(linear,0 0,0 100%,from(#fff),to(#e6e6e6));background-image:-webkit-linear-gradient(top,#fff,#e6e6e6);background-image:-o-linear-gradient(top,#fff,#e6e6e6);background-image:linear-gradient(to bottom,#fff,#e6e6e6);background-repeat:repeat-x;border-color:#e6e6e6 #e6e6e6 #bfbfbf;border-color:rgba(0,0,0,.1) rgba(0,0,0,.1) rgba(0,0,0,.25);color:#333;text-shadow:0 1px 1px rgba(255,255,255,.75);box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 1px 2px rgba(0,0,0,.05)}.fc-state-active,.fc-state-disabled,.fc-state-down,.fc-state-hover{color:#333;background-color:#e6e6e6}.fc-state-hover{color:#333;text-decoration:none;background-position:0 -15px;-webkit-transition:background-position .1s linear;-moz-transition:background-position .1s linear;-o-transition:background-position .1s linear;transition:background-position .1s linear}.fc-state-active,.fc-state-down{background-color:#ccc;background-image:none;box-shadow:inset 0 2px 4px rgba(0,0,0,.15),0 1px 2px rgba(0,0,0,.05)}.fc-state-disabled{cursor:default;background-image:none;opacity:.65;filter:alpha(opacity=65);box-shadow:none}.fc-button-group{display:inline-block}.fc .fc-button-group>*{float:left;margin:0 0 0 -1px}.fc .fc-button-group>:first-child{margin-left:0}.fc-popover{position:absolute;box-shadow:0 2px 6px rgba(0,0,0,.15)}.fc-popover .fc-header{padding:2px 4px}.fc-popover .fc-header .fc-title{margin:0 2px}.fc-popover .fc-header .fc-close{cursor:pointer}.fc-ltr .fc-popover .fc-header .fc-title,.fc-rtl .fc-popover .fc-header .fc-close{float:left}.fc-ltr .fc-popover .fc-header .fc-close,.fc-rtl .fc-popover .fc-header .fc-title{float:right}.fc-unthemed .fc-popover{border-width:1px;border-style:solid}.fc-unthemed .fc-popover .fc-header .fc-close{font-size:.9em;margin-top:2px}.fc-popover>.ui-widget-header+.ui-widget-content{border-top:0}.fc-divider{border-style:solid;border-width:1px}hr.fc-divider{height:0;margin:0;padding:0 0 2px;border-width:1px 0}.fc-clear{clear:both}.fc-bg,.fc-bgevent-skeleton,.fc-helper-skeleton,.fc-highlight-skeleton{position:absolute;top:0;left:0;right:0}.fc-bg{bottom:0}.fc-bg table{height:100%}.fc table{width:100%;table-layout:fixed;border-collapse:collapse;border-spacing:0;font-size:1em}.fc th{text-align:center}.fc td,.fc th{border-style:solid;border-width:1px;padding:0;vertical-align:top}.fc td.fc-today{border-style:double}.fc .fc-row{border-style:solid;border-width:0}.fc-row table{border-left:0 hidden transparent;border-right:0 hidden transparent;border-bottom:0 hidden transparent}.fc-row:first-child table{border-top:0 hidden transparent}.fc-row{position:relative}.fc-row .fc-bg{z-index:1}.fc-row .fc-bgevent-skeleton,.fc-row .fc-highlight-skeleton{bottom:0}.fc-row .fc-bgevent-skeleton table,.fc-row .fc-highlight-skeleton table{height:100%}.fc-row .fc-bgevent-skeleton td,.fc-row .fc-highlight-skeleton td{border-color:transparent}.fc-row .fc-bgevent-skeleton{z-index:2}.fc-row .fc-highlight-skeleton{z-index:3}.fc-row .fc-content-skeleton{position:relative;z-index:4;padding-bottom:2px}.fc-row .fc-helper-skeleton{z-index:5}.fc-row .fc-content-skeleton td,.fc-row .fc-helper-skeleton td{background:0 0;border-color:transparent;border-bottom:0}.fc-row .fc-content-skeleton tbody td,.fc-row .fc-helper-skeleton tbody td{border-top:0}.fc-scroller{overflow-y:scroll;overflow-x:hidden}.fc-scroller>*{position:relative;width:100%;overflow:hidden}.fc-event{position:relative;display:block;font-size:.85em;line-height:1.3;border-radius:3px;border:1px solid #3a87ad;background-color:#3a87ad;font-weight:400}.fc-event,.fc-event:hover,.ui-widget .fc-event{color:#fff;text-decoration:none}.fc-event.fc-draggable,.fc-event[href]{cursor:pointer}.fc-not-allowed,.fc-not-allowed .fc-event{cursor:not-allowed}.fc-event .fc-bg{z-index:1;background:#fff;opacity:.25;filter:alpha(opacity=25)}.fc-event .fc-content{position:relative;z-index:2}.fc-event .fc-resizer{position:absolute;z-index:3}.fc-ltr .fc-h-event.fc-not-start,.fc-rtl .fc-h-event.fc-not-end{margin-left:0;border-left-width:0;padding-left:1px;border-top-left-radius:0;border-bottom-left-radius:0}.fc-ltr .fc-h-event.fc-not-end,.fc-rtl .fc-h-event.fc-not-start{margin-right:0;border-right-width:0;padding-right:1px;border-top-right-radius:0;border-bottom-right-radius:0}.fc-h-event .fc-resizer{top:-1px;bottom:-1px;left:-1px;right:-1px;width:5px}.fc-ltr .fc-h-event .fc-start-resizer,.fc-ltr .fc-h-event .fc-start-resizer:after,.fc-ltr .fc-h-event .fc-start-resizer:before,.fc-rtl .fc-h-event .fc-end-resizer,.fc-rtl .fc-h-event .fc-end-resizer:after,.fc-rtl .fc-h-event .fc-end-resizer:before{right:auto;cursor:w-resize}.fc-ltr .fc-h-event .fc-end-resizer,.fc-ltr .fc-h-event .fc-end-resizer:after,.fc-ltr .fc-h-event .fc-end-resizer:before,.fc-rtl .fc-h-event .fc-start-resizer,.fc-rtl .fc-h-event .fc-start-resizer:after,.fc-rtl .fc-h-event .fc-start-resizer:before{left:auto;cursor:e-resize}.fc-day-grid-event{margin:1px 2px 0;padding:0 1px}.fc-day-grid-event .fc-content{white-space:nowrap;overflow:hidden}.fc-day-grid-event .fc-time{font-weight:700}.fc-day-grid-event .fc-resizer{left:-3px;right:-3px;width:7px}a.fc-more{margin:1px 3px;font-size:.85em;cursor:pointer;text-decoration:none}a.fc-more:hover{text-decoration:underline}.fc-limited{display:none}.fc-day-grid .fc-row{z-index:1}.fc-more-popover{z-index:2;width:220px}.fc-more-popover .fc-event-container{padding:10px}.fc-toolbar{text-align:center;margin-bottom:1em}.fc-toolbar .fc-left{float:left}.fc-toolbar .fc-right{float:right}.fc-toolbar .fc-center{display:inline-block}.fc .fc-toolbar>*>*{float:left;margin-left:.75em}.fc .fc-toolbar>*>:first-child{margin-left:0}.fc-toolbar h2{margin:0}.fc-toolbar button{position:relative}.fc-toolbar .fc-state-hover,.fc-toolbar .ui-state-hover{z-index:2}.fc-toolbar .fc-state-down{z-index:3}.fc-toolbar .fc-state-active,.fc-toolbar .ui-state-active{z-index:4}.fc-toolbar button:focus{z-index:5}.fc-view-container *,.fc-view-container :after,.fc-view-container :before{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.fc-view,.fc-view>table{position:relative;z-index:1}.fc-basicDay-view .fc-content-skeleton,.fc-basicWeek-view .fc-content-skeleton{padding-top:1px;padding-bottom:1em}.fc-basic-view .fc-body .fc-row{min-height:4em}.fc-row.fc-rigid{overflow:hidden}.fc-row.fc-rigid .fc-content-skeleton{position:absolute;top:0;left:0;right:0}.fc-basic-view .fc-day-number,.fc-basic-view .fc-week-number{padding:0 2px}.fc-basic-view td.fc-day-number,.fc-basic-view td.fc-week-number span{padding-top:2px;padding-bottom:2px}.fc-basic-view .fc-week-number{text-align:center}.fc-basic-view .fc-week-number span{display:inline-block;min-width:1.25em}.fc-ltr .fc-basic-view .fc-day-number{text-align:right}.fc-rtl .fc-basic-view .fc-day-number{text-align:left}.fc-day-number.fc-other-month{opacity:.3;filter:alpha(opacity=30)}.fc-agenda-view .fc-day-grid{position:relative;z-index:2}.fc-agenda-view .fc-day-grid .fc-row{min-height:3em}.fc-agenda-view .fc-day-grid .fc-row .fc-content-skeleton{padding-top:1px;padding-bottom:1em}.fc .fc-axis{vertical-align:middle;padding:0 4px;white-space:nowrap}.fc-ltr .fc-axis{text-align:right}.fc-rtl .fc-axis{text-align:left}.ui-widget td.fc-axis{font-weight:400}.fc-time-grid,.fc-time-grid-container{position:relative;z-index:1}.fc-time-grid{min-height:100%}.fc-time-grid table{border:0 hidden transparent}.fc-time-grid>.fc-bg{z-index:1}.fc-time-grid .fc-slats,.fc-time-grid>hr{position:relative;z-index:2}.fc-time-grid .fc-bgevent-skeleton,.fc-time-grid .fc-content-skeleton{position:absolute;top:0;left:0;right:0}.fc-time-grid .fc-bgevent-skeleton{z-index:3}.fc-time-grid .fc-highlight-skeleton{z-index:4}.fc-time-grid .fc-content-skeleton{z-index:5}.fc-time-grid .fc-helper-skeleton{z-index:6}.fc-time-grid .fc-slats td{height:1.5em;border-bottom:0}.fc-time-grid .fc-slats .fc-minor td{border-top-style:dotted}.fc-time-grid .fc-slats .ui-widget-content{background:0 0}.fc-time-grid .fc-highlight-container{position:relative}.fc-time-grid .fc-highlight{position:absolute;left:0;right:0}.fc-time-grid .fc-bgevent-container,.fc-time-grid .fc-event-container{position:relative}.fc-ltr .fc-time-grid .fc-event-container{margin:0 2.5% 0 2px}.fc-rtl .fc-time-grid .fc-event-container{margin:0 2px 0 2.5%}.fc-time-grid .fc-bgevent,.fc-time-grid .fc-event{position:absolute;z-index:1}.fc-time-grid .fc-bgevent{left:0;right:0}.fc-v-event.fc-not-start{border-top-width:0;padding-top:1px;border-top-left-radius:0;border-top-right-radius:0}.fc-v-event.fc-not-end{border-bottom-width:0;padding-bottom:1px;border-bottom-left-radius:0;border-bottom-right-radius:0}.fc-time-grid-event{overflow:hidden}.fc-time-grid-event .fc-time,.fc-time-grid-event .fc-title{padding:0 1px}.fc-time-grid-event .fc-time{font-size:.85em;white-space:nowrap}.fc-time-grid-event.fc-short .fc-content{white-space:nowrap}.fc-time-grid-event.fc-short .fc-time,.fc-time-grid-event.fc-short .fc-title{display:inline-block;vertical-align:top}.fc-time-grid-event.fc-short .fc-time span{display:none}.fc-time-grid-event.fc-short .fc-time:before{content:attr(data-start)}.fc-time-grid-event.fc-short .fc-time:after{content:"\000A0-\000A0"}.fc-time-grid-event.fc-short .fc-title{font-size:.85em;padding:0}.fc-time-grid-event .fc-resizer{left:0;right:0;bottom:0;height:8px;overflow:hidden;line-height:8px;font-size:11px;font-family:monospace;text-align:center;cursor:s-resize}.fc-time-grid-event .fc-resizer:after{content:"="}/*!
- * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
- */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.4.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.4.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.4.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.4.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.4.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.4.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}
-.c3 svg{font:10px sans-serif}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}header,.sidebar,.form-comment,.page-header{display:none}a{color:#36c;border:0}a:focus{outline:0;color:#df5353;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}th,td{border:1px solid #eee;padding-top:.5em;padding-bottom:.5em;padding-left:3px;padding-right:3px}td{vertical-align:top}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a{text-decoration:none;color:#333}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-fixed th{overflow:hidden}.table-fixed td{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-stripped tr:nth-child(odd) td{background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70{width:70%}.public-board{margin-top:5px}.public-task{max-width:800px;margin:0 auto;margin-top:5px}#board-container{overflow-x:auto}#board{table-layout:fixed}#board th.board-column-header{width:240px}#board td{vertical-align:top}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:bold;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title{cursor:pointer}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36c;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:normal}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:hover,a.board-swimlane-toggle:focus{color:#000;text-decoration:none;border:0}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#df5353}.draggable-item{cursor:pointer;user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-board{position:relative;margin-bottom:4px;border:1px solid #000;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{box-shadow:2px 2px 3px rgba(0,0,0,0.2)}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-table a,.task-board a{color:#000;text-decoration:none;font-weight:bold}.task-table a:focus,.task-table a:hover,.task-board a:focus,.task-board a:hover{text-decoration:underline}.task-board-collapsed{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}a.task-board-collapsed-title{font-weight:normal}.task-board .dropdown{font-size:1.1em}.task-board-title{margin-top:5px;margin-bottom:5px;font-size:1.1em}.task-board-title a{font-weight:normal}.task-board-user{font-size:.8em}.task-board-current-user a{text-decoration:underline}.task-board-current-user a:focus,.task-board-current-user a:hover{text-decoration:none}a.task-board-nobody{font-weight:normal;font-style:italic;color:#444}.task-board-category-container{text-align:right}.task-board-category{font-weight:bold;font-size:.9em;color:#000;border:1px solid #555;padding:2px;padding-right:5px;padding-left:5px}.task-board-icons{text-align:right;margin-top:8px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1.0}.task-board-date{font-weight:bold;color:#000}span.task-board-date-overdue{color:#d90000;opacity:1.0}.task-score{font-weight:bold}.task-board .task-score{font-size:1.1em}.task-show-details .task-score{position:absolute;bottom:5px;right:5px;font-size:2em}.task-board-closed,.task-board-days{position:absolute;right:5px;top:5px;opacity:.5;font-size:.8em}.task-board-days:hover{opacity:1.0}.task-days-age{border:#666 1px solid;padding:1px 4px 1px 2px;border-top-left-radius:3px;border-bottom-left-radius:3px}.task-days-incolumn{border:#666 1px solid;border-left:0;margin-left:-5px;padding:1px 2px 1px 4px;border-top-right-radius:3px;border-bottom-right-radius:3px}.board-container-compact .task-board-days{display:none}.task-show-details{position:relative;border-radius:5px;padding-bottom:10px}.task-show-details h2{font-size:1.8em;margin:0;margin-bottom:25px;padding:0;padding-left:10px;padding-right:10px}.task-show-details li{margin-left:25px;list-style-type:circle}.task-show-section{margin-top:30px;margin-bottom:20px}.task-show-files a{font-weight:bold;text-decoration:none}.task-show-files li{margin-left:25px;list-style-type:square;line-height:25px}.task-show-file-actions{font-size:.75em}.task-show-file-actions:before{content:" ["}.task-show-file-actions:after{content:"]"}.task-show-file-actions a{color:#333}.task-show-description{border-left:4px solid #333;padding-left:20px}.task-show-description-textarea{width:99%;max-width:99%;height:300px}.task-file-viewer{position:relative}.task-file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.task-time-form{margin-top:10px;margin-bottom:25px;padding:3px}.task-link-closed{text-decoration:line-through}.task-show-images{list-style-type:none}.task-show-images li img{width:100%}.task-show-images li .img_container{width:250px;height:100px;overflow:hidden}.task-show-images li{padding:10px;overflow:auto;width:250px;min-height:120px;display:inline-block;vertical-align:top}.task-show-images li p{padding:5px;font-weight:bold}.task-show-images li:hover{background:#eee}.task-show-image-actions{margin-left:5px}.task-show-file-table{width:auto}.task-show-start-link{color:#000}.task-show-start-link:hover,.task-show-start-link:focus{color:red}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000;cursor:pointer}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,0.9)}.comment{margin-bottom:20px}.comment:hover{background:#f7f8e0}.comment-inner{border-left:4px solid #333;padding-bottom:10px;padding-left:20px;margin-left:20px;margin-right:10px}.comment-preview{border:2px solid #000;border-radius:3px;padding:10px}.comment-preview .comment-inner{border:0;padding:0;margin:0}.comment-title{margin-bottom:8px;padding-bottom:3px;border-bottom:1px dotted #aaa}.ui-tooltip .comment-title{font-size:80%}.ui-tooltip .comment-inner{padding-bottom:0}.comment-actions{font-size:.8em;padding:0;text-align:right}.comment-actions li{display:inline;padding-left:5px;padding-right:5px;border-right:1px dotted #000}.comment-actions li:last-child{padding-right:0;border:0}.comment-username{font-weight:bold}.comment-textarea{height:200px;width:80%;max-width:800px}.comment-sorting{font-size:.5em}span.comment-sorting a{color:#555;font-weight:normal;text-decoration:none}span.comment-sorting a:hover{color:#aaa}#comments .comment-textarea{height:80px;width:500px}.subtasks-table{font-size:.85em}.subtasks-table td{vertical-align:middle}.markdown{line-height:1.4em;font-size:1.0}.markdown h1{margin-top:5px;margin-bottom:10px;font-size:1.5em;font-weight:bold;text-decoration:underline}.markdown h2{font-size:1.2em;font-weight:bold;text-decoration:underline}.markdown h3{font-size:1.1em;text-decoration:underline}.markdown h4{font-size:1.1em;text-decoration:underline}.markdown p{margin-bottom:10px}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-bottom:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px} \ No newline at end of file
+ */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.5.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}
+.c3 svg{font:10px sans-serif}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}header,.sidebar,.form-comment,.page-header{display:none}a{color:#36c;border:0}a:focus{outline:0;color:#df5353;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}th,td{border:1px solid #eee;padding-top:.5em;padding-bottom:.5em;padding-left:3px;padding-right:3px}td{vertical-align:top}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a{text-decoration:none;color:#333}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-fixed th{overflow:hidden}.table-fixed td{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,0.55)}tr.draggable-item-selected td{border-top:0;border-bottom:0}tr.draggable-item-selected td:first-child{border-left:0}tr.draggable-item-selected td:last-child{border-right:0}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#fefff2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:0 auto;margin-top:5px}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}#board td{vertical-align:top}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:bold;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36c;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:normal}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:hover,a.board-swimlane-toggle:focus{color:#000;text-decoration:none;border:0}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#df5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-board-saving-state{opacity:.3}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board{position:relative;margin-bottom:4px;border:1px solid #000;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-table a,.task-board a{color:#000;text-decoration:none;font-weight:bold}.task-table a:focus,.task-table a:hover,.task-board a:focus,.task-board a:hover{text-decoration:underline}.task-board-collapsed{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}a.task-board-collapsed-title{font-weight:normal}.task-board .dropdown{font-size:1.1em}.task-board-title{margin-top:5px;margin-bottom:5px;font-size:1.1em}.task-board-title a{font-weight:normal}.task-board-user{font-size:.8em}.task-board-current-user a{text-decoration:underline}.task-board-current-user a:focus,.task-board-current-user a:hover{text-decoration:none}a.task-board-nobody{font-weight:normal;font-style:italic;color:#444}.task-board-category-container{text-align:right}.task-board-category{font-weight:bold;font-size:.9em;color:#000;border:1px solid #555;padding:2px;padding-right:5px;padding-left:5px}.task-board-icons{text-align:right;margin-top:8px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1.0}.task-board-date{font-weight:bold;color:#000}span.task-board-date-overdue{color:#d90000;opacity:1.0}.task-board .task-score{font-weight:bold;font-size:1.1em}.task-board-closed,.task-board-days{position:absolute;right:5px;top:5px;opacity:.5;font-size:.8em}.task-board-days:hover{opacity:1.0}.task-days-age{border:#666 1px solid;padding:1px 4px 1px 2px;border-top-left-radius:3px;border-bottom-left-radius:3px}.task-days-incolumn{border:#666 1px solid;border-left:0;margin-left:-5px;padding:1px 2px 1px 4px;border-top-right-radius:3px;border-bottom-right-radius:3px}.board-container-compact .task-board-days{display:none}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px;display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-description{border-left:4px solid #333;padding-left:20px}.task-show-description-textarea{width:99%;max-width:99%;height:300px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000;cursor:pointer}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,0.9)}.assign-me{font-size:.8em;vertical-align:bottom}.comment{margin-bottom:20px}.comment:hover{background:#f7f8e0}.comment-inner{border-left:4px solid #333;padding-bottom:10px;padding-left:20px;margin-left:20px;margin-right:10px}.comment-preview{border:2px solid #000;border-radius:3px;padding:10px}.comment-preview .comment-inner{border:0;padding:0;margin:0}.comment-title{margin-bottom:8px;padding-bottom:3px;border-bottom:1px dotted #aaa}.ui-tooltip .comment-title{font-size:80%}.ui-tooltip .comment-inner{padding-bottom:0}.comment-actions{font-size:.8em;padding:0;text-align:right}.comment-actions li{display:inline;padding-left:5px;padding-right:5px;border-right:1px dotted #000}.comment-actions li:last-child{padding-right:0;border:0}.comment-username{font-weight:bold}.comment-textarea{height:200px;width:80%;max-width:800px}.comment-sorting{font-size:.5em}span.comment-sorting a{color:#555;font-weight:normal;text-decoration:none}span.comment-sorting a:hover{color:#aaa}#comments .comment-textarea{height:80px;width:500px}.subtasks-table{font-size:.85em}.subtasks-table td{vertical-align:middle}.markdown{line-height:1.4em;font-size:1.0}.markdown h1{margin-top:5px;margin-bottom:10px;font-size:1.5em;font-weight:bold;text-decoration:underline}.markdown h2{font-size:1.2em;font-weight:bold;text-decoration:underline}.markdown h3{font-size:1.1em;text-decoration:underline}.markdown h4{font-size:1.1em;text-decoration:underline}.markdown p{margin-bottom:10px}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-bottom:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:bold;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file
diff --git a/assets/css/src/base.css b/assets/css/src/base.css
index 8b15ac16..7f5642e7 100644
--- a/assets/css/src/base.css
+++ b/assets/css/src/base.css
@@ -17,7 +17,7 @@ body {
body {
margin-left: 10px;
margin-right: 10px;
- padding-bottom: 20px;
+ padding-bottom: 10px;
color: #333;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
text-rendering: optimizeLegibility;
@@ -71,3 +71,6 @@ hr {
color: #000;
}
+.smaller {
+ font-size: 0.85em;
+}
diff --git a/assets/css/src/board.css b/assets/css/src/board.css
index c59ccdf0..586093b8 100644
--- a/assets/css/src/board.css
+++ b/assets/css/src/board.css
@@ -16,6 +16,7 @@
#board {
table-layout: fixed;
+ margin-bottom: 0;
}
#board th.board-column-header {
@@ -79,8 +80,8 @@ td.board-column-task-collapsed {
}
/* column header */
-.board-column-title {
- cursor: pointer;
+.board-column-title .dropdown-menu {
+ text-decoration: none;
}
.board-add-icon {
@@ -137,6 +138,8 @@ a.board-swimlane-toggle:focus {
.draggable-item {
cursor: pointer;
user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
}
.draggable-placeholder {
@@ -154,3 +157,16 @@ div.draggable-item-selected {
float: left;
padding-right: 5px;
}
+
+/* board saving state */
+.task-board-saving-state {
+ opacity: 0.3;
+}
+
+.task-board-saving-icon {
+ position: absolute;
+ margin: auto;
+ width: 100%;
+ text-align: center;
+ color: #000;
+}
diff --git a/assets/css/src/dropdown.css b/assets/css/src/dropdown.css
index 30e5c22a..7d967b06 100644
--- a/assets/css/src/dropdown.css
+++ b/assets/css/src/dropdown.css
@@ -21,6 +21,7 @@ ul.dropdown-submenu-open {
box-shadow: 0px 1px 3px rgba(0,0,0,0.15);
}
+.textarea-dropdown li,
.dropdown-submenu-open li {
display: block;
margin: 0;
@@ -31,21 +32,32 @@ ul.dropdown-submenu-open {
padding-bottom: 8px;
font-size: 0.85em;
border-bottom: 1px solid #f8f8f8;
+ cursor: pointer;
}
+.dropdown-submenu-open li.no-hover {
+ cursor: default;
+}
+
+.textarea-dropdown li:last-child,
.dropdown-submenu-open li:last-child {
border: none;
}
-.dropdown-submenu-open li:hover {
+.textarea-dropdown .active,
+.textarea-dropdown li:hover,
+.dropdown-submenu-open li:not(.no-hover):hover {
background: #4078C0;
color: #fff;
}
+.textarea-dropdown .active a,
+.textarea-dropdown li:hover a,
.dropdown-submenu-open li:hover a {
color: #fff;
}
+.textarea-dropdown a,
.dropdown-submenu-open a {
text-decoration: none;
color: #333;
@@ -58,3 +70,24 @@ ul.dropdown-submenu-open {
.page-header .dropdown {
padding-right: 10px;
}
+
+.dropdown-menu-link-text,
+.dropdown-menu-link-icon {
+ color: #333;
+ text-decoration: none;
+}
+
+.dropdown-menu-link-text:hover {
+ text-decoration: underline;
+}
+
+/* textarea dropdown */
+.textarea-dropdown {
+ list-style: none;
+ margin: 3px 0 0 1px;
+ padding: 6px 0;
+ background-color: #fff;
+ border: 1px solid #b2b2b2;
+ border-radius: 3px;
+ box-shadow: 0px 1px 3px rgba(0,0,0,0.15);
+}
diff --git a/assets/css/src/files.css b/assets/css/src/files.css
new file mode 100644
index 00000000..a81b387b
--- /dev/null
+++ b/assets/css/src/files.css
@@ -0,0 +1,56 @@
+.file-thumbnails {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ flex-direction: row;
+ -webkit-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -webkit-justify-content: flex-start;
+ justify-content: flex-start;
+}
+
+.file-thumbnail {
+ width: 250px;
+ border: 1px solid #efefef;
+ border-radius: 5px;
+ margin-bottom: 20px;
+ box-shadow: 4px 2px 10px -6px rgba(0,0,0,0.55);
+ margin-right: 15px;
+}
+
+.file-thumbnail img {
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+}
+
+.file-thumbnail img:hover {
+ opacity: 0.5;
+}
+
+.file-thumbnail-content {
+ padding-left: 8px;
+ padding-right: 8px;
+}
+
+.file-thumbnail-title {
+ font-weight: 700;
+ font-size: 0.9em;
+ color: #555;
+}
+
+.file-thumbnail-description {
+ font-size: 0.8em;
+ color: #aaa;
+ margin-top: 8px;
+ margin-bottom: 5px;
+}
+
+.file-viewer {
+ position: relative;
+}
+
+.file-viewer img {
+ max-width: 95%;
+ max-height: 85%;
+ margin-top: 10px;
+}
diff --git a/assets/css/src/filters.css b/assets/css/src/filters.css
index 8f889556..0e0a35e7 100644
--- a/assets/css/src/filters.css
+++ b/assets/css/src/filters.css
@@ -1,68 +1,57 @@
-.toolbar {
- font-size: 0.9em;
- padding-top: 5px;
+.project-header {
+ margin-top: 8px;
+ margin-bottom: 20px;
}
-.views {
+.filter-box {
display: inline-block;
- margin-right: 10px;
- font-size: 0.9em;
+ position: relative;
+ font-size: 0;
+ margin-bottom: 20px;
}
-.views li {
- border: 1px solid #eee;
- padding-left: 8px;
- padding-right: 8px;
- padding-top: 5px;
- padding-bottom: 5px;
- display: inline;
+.project-header .filter-box {
+ margin: 0;
}
-.menu-inline li.active a,
-.views li.active a {
- font-weight: bold;
- color: #000;
- text-decoration: none;
+.filter-box form {
+ margin: 0;
}
-.views li:first-child {
- border-right: none;
+.filter-box input[type="text"] {
+ margin: 0;
+ font-size: 16px;
+ height: 26px;
+ border-color: #dedede;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
+ vertical-align: top;
}
-.views li:last-child {
- border-left: none;
- border-top-right-radius: 5px;
- border-bottom-right-radius: 5px;
+.filter-box input[type="text"]:focus {
+ color: #000;
+ border-color: rgba(82, 168, 236, 0.8);
+ outline: 0;
+ box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
}
-.filters {
+.filter-box div.dropdown {
display: inline-block;
- border: 1px solid #eee;
- border-radius: 5px;
- padding: 5px;
- padding-right: 10px;
- margin-left: 8px;
-}
-
-.filters ul {
- font-size: 0.8em;
-}
-
-.page-header .filters ul {
- font-size: 0.9em;
-}
-
-form.search {
- display: inline;
+ font-size: 16px;
+ border: 1px solid #dedede;
+ border-left: none;
+ margin: 0;
+ padding: 0;
+ padding-left: 5px;
+ padding-right: 8px;
+ height: 27px;
}
-div.search {
- margin-bottom: 20px;
+.filter-box div.dropdown:last-child {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
}
-.filter-dropdowns {
- font-size: 0.9em;
- display: inline-block;
+.filter-box div.dropdown a {
+ line-height: 27px;
}
diff --git a/assets/css/src/form.css b/assets/css/src/form.css
index 79bdf7bd..e42b345b 100644
--- a/assets/css/src/form.css
+++ b/assets/css/src/form.css
@@ -146,11 +146,6 @@ input.form-input-large {
width: 400px;
}
-.form-row {
- margin-top: 10px;
- margin-bottom: 20px;
-}
-
.form-column {
float: left;
margin-right: 3%;
@@ -161,6 +156,12 @@ input.form-input-large {
margin-top: 15px;
}
+.form-clear {
+ clear: both;
+ padding-top: 20px;
+ padding-bottom: 10px;
+}
+
.form-login {
width: 350px;
margin: 0 auto;
@@ -173,6 +174,12 @@ input.form-input-large {
line-height: 25px;
}
+.form-login h2 {
+ margin-bottom: 30px;
+ font-size: 1.5em;
+ font-weight: bold;
+}
+
/* preview tabs */
label + .form-tabs {
margin-top: 10px;
@@ -221,3 +228,12 @@ ul.form-tabs-nav {
display: none;
overflow: auto;
}
+
+.reset-password {
+ margin-top: 20px;
+}
+
+.reset-password a {
+ font-size: 0.8em;
+ color: #999;
+}
diff --git a/assets/css/src/header.css b/assets/css/src/header.css
index 74b0ecec..f4903128 100644
--- a/assets/css/src/header.css
+++ b/assets/css/src/header.css
@@ -35,19 +35,9 @@ nav .active a {
font-weight: bold;
}
-/* username */
-.username a {
- color: #000;
-}
-
-.username a:hover {
- color: #DF5353;
- text-decoration: underline;
-}
-
/* logo */
-.logo {
- opacity: 0.3;
+.logo a {
+ opacity: 0.5;
color: #d40000;
}
@@ -55,15 +45,27 @@ nav .active a {
color: #333;
}
-.logo:hover {
+.logo a:hover {
opacity: 0.8;
+ color: #333;
}
-.logo:focus span,
-.logo:hover span {
+.logo a:focus span,
+.logo a:hover span {
color: #d40000;
}
+/* user links on the left */
+header .user-links .dropdown {
+ margin-left: 15px;
+}
+
+/* title tooltip */
+header h1 .tooltip {
+ opacity: 0.3;
+ font-size: 0.6em;
+}
+
/* page header */
.page-header {
margin-bottom: 20px;
@@ -78,12 +80,13 @@ nav .active a {
}
.page-header h2 a {
- color: #ddd;
+ color: #333;
+ text-decoration: none;
}
.page-header h2 a:focus,
.page-header h2 a:hover {
- color: #333;
+ color: #aaa;
}
.page-header ul {
@@ -95,16 +98,26 @@ nav .active a {
.menu-inline li,
.page-header li {
display: inline;
- padding-right: 10px;
+ padding-right: 15px;
font-size: 0.95em;
}
+.page-header li.active a {
+ color: #333;
+ text-decoration: none;
+ font-weight: bold;
+}
+
+.page-header li.active a:hover,
+.page-header li.active a:focus {
+ text-decoration: underline;
+}
+
.menu-inline {
margin-bottom: 5px;
}
@media only screen and (max-width: 640px) {
-
.page-header-mobile li {
display: block;
margin-bottom: 5px;
diff --git a/assets/css/src/listing.css b/assets/css/src/listing.css
index c40c4821..e96197e4 100644
--- a/assets/css/src/listing.css
+++ b/assets/css/src/listing.css
@@ -5,7 +5,7 @@
margin-bottom: 20px;
border: 1px solid #ddd;
color: #333;
- background-color: #fefefe;
+ background-color: #fcfcfc;
overflow: auto;
}
diff --git a/assets/css/src/markdown.css b/assets/css/src/markdown.css
index 4e37bbca..8e0d482a 100644
--- a/assets/css/src/markdown.css
+++ b/assets/css/src/markdown.css
@@ -93,3 +93,13 @@
.documentation li {
line-height: 30px;
}
+
+.user-mention-link {
+ font-weight: bold;
+ color: #000;
+ text-decoration: none;
+}
+
+.user-mention-link:hover {
+ color: #555;
+}
diff --git a/assets/css/src/project.css b/assets/css/src/project.css
new file mode 100644
index 00000000..7a77067f
--- /dev/null
+++ b/assets/css/src/project.css
@@ -0,0 +1,42 @@
+.project-creation-options {
+ max-width: 500px;
+ border-left: 3px dotted #efefef;
+ margin-top: 20px;
+ padding-left: 15px;
+ padding-bottom: 5px;
+ padding-top: 5px;
+}
+
+.project-overview-columns {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ flex-direction: row;
+
+ -webkit-flex-wrap: wrap;
+ flex-wrap: wrap;
+
+ -webkit-align-items: center;
+ align-items: center;
+
+ -webkit-justify-content: center;
+ justify-content: center;
+
+ margin-bottom: 20px;
+ font-size: 1.4em;
+}
+
+.project-overview-column {
+ text-align: center;
+ margin-right: 80px;
+}
+
+.project-overview-column strong {
+ font-size: 1.3em;
+ color: #444;
+}
+
+.project-overview-column span {
+ font-size: 0.8em;
+ color: #777;
+}
diff --git a/assets/css/src/responsive.css b/assets/css/src/responsive.css
index bfefabcf..c94be166 100644
--- a/assets/css/src/responsive.css
+++ b/assets/css/src/responsive.css
@@ -1,21 +1,5 @@
-@media only screen and (max-width: 1080px) {
- div.filter-dropdowns .filters {
- margin-left: 0;
- }
-
- div.filter-dropdowns {
- display: block;
- margin-top: 5px;
- }
-}
-
@media only screen and (max-width: 1024px) {
- li.hide-tablet,
- .hide-tablet {
- display: none;
- }
-
body {
font-size: 0.85em;
}
@@ -37,15 +21,6 @@
max-width: 150px;
}
- .task-time-form label {
- display: block;
- }
-
- .task-time-form input[type="submit"] {
- margin-top: 10px;
- display: block;
- }
-
.page-header .form-input-large {
width: 300px;
}
diff --git a/assets/css/src/screenshot.css b/assets/css/src/screenshot.css
deleted file mode 100644
index 4d917200..00000000
--- a/assets/css/src/screenshot.css
+++ /dev/null
@@ -1,19 +0,0 @@
-#screenshot-zone {
- position: relative;
- border: 2px dashed #ccc;
- width: 90%;
- height: 250px;
- overflow: auto;
-}
-
-#screenshot-inner {
- position: absolute;
- left: 0;
- bottom: 48%;
- width: 100%;
- text-align: center;
-}
-
-#screenshot-zone.screenshot-pasted {
- border: 2px solid #333;
-} \ No newline at end of file
diff --git a/assets/css/src/sidebar.css b/assets/css/src/sidebar.css
index a9d56865..c8e6b779 100644
--- a/assets/css/src/sidebar.css
+++ b/assets/css/src/sidebar.css
@@ -1,90 +1,68 @@
-/* sidebar */
.sidebar-container {
margin-top: 10px;
- position: relative;
- clear: both;
+ height: 100%;
+ width: 100%;
+ display: -ms-flexbox;
+ display: -webkit-box;
+ display: -moz-box;
+ display: -ms-box;
+ display: box;
+ -ms-flex-direction: row;
+ -webkit-box-orient: horizontal;
+ -moz-box-orient: horizontal;
+ -ms-box-orient: horizontal;
+ box-orient: horizontal;
}
.sidebar-content {
- margin-left: 23%;
- width: 76%;
- position: absolute;
+ padding-left: 10px;
+ -ms-flex: 1;
+ -webkit-box-flex: 1;
+ -moz-box-flex: 1;
+ -ms-box-flex: 1;
+ box-flex: 1;
}
.sidebar {
- width: 20%;
- float: left;
- padding: 10px;
- padding-top: 0;
- border: 1px solid #ddd;
- background: #fdfdfd;
- border-radius: 5px;
-}
-
-.sidebar li {
- list-style-type: square;
- margin-left: 30px;
- line-height: 1.8em;
+ padding-right: 10px;
+ border-right: 1px dotted #eee;
+ font-size: 0.95em;
+ max-width: 240px;
+ min-width: 190px;
+ width: 18%;
+ -ms-flex: 0 100px;
+ -webkit-box-flex: 0;
+ -moz-box-flex: 0;
+ -ms-box-flex: 0;
+ box-flex: 0;
}
-.sidebar li.active a {
- color: #000;
- font-weight: bold;
+.sidebar a {
text-decoration: none;
}
-.sidebar li.active a:focus,
-.sidebar li.active a:hover {
- text-decoration: underline;
-}
-
-.sidebar-collapsed .sidebar {
- width: 10px;
- padding-bottom: 0;
- float: none;
+.sidebar li {
+ list-style-type: none;
+ line-height: 35px;
+ border-bottom: 1px dotted #efefef;
}
-.sidebar-collapsed .sidebar-content {
- margin: 0;
- margin-top: 15px;
- width: 100%;
+.sidebar li:hover {
+ border-left: 5px solid #555;
+ padding-left: 8px;
}
-.sidebar-collapse {
- text-align: right;
+.sidebar li.active {
+ border-left: 5px solid #333;
+ padding-left: 8px;
}
-.sidebar-collapse a,
-.sidebar-expand a {
+.sidebar li.active a {
color: #333;
- text-decoration: none;
-}
-
-.sidebar-collapse a:hover,
-.sidebar-expand a:hover {
- color: #DF5353;
-}
-
-@media only screen and (max-width: 1024px) {
- .sidebar {
- width: 25%;
- }
-
- .sidebar-content {
- margin-left: 30%;
- width: 70%;
- }
+ font-weight: bold;
}
-@media only screen and (max-width: 767px) {
- .sidebar {
- width: 95%;
- float: none;
- }
-
- .sidebar-content {
- margin: 0;
- margin-top: 15px;
- width: 100%;
- }
+.sidebar li.active a:focus,
+.sidebar li.active a:hover {
+ color: #555;
}
diff --git a/assets/css/src/table.css b/assets/css/src/table.css
index 51d6ecde..49569381 100644
--- a/assets/css/src/table.css
+++ b/assets/css/src/table.css
@@ -62,7 +62,7 @@ th a:hover {
text-overflow: ellipsis;
}
-.table-stripped tr:nth-child(odd) td {
+.table-stripped tr:nth-child(odd) {
background: #fefefe;
}
@@ -124,4 +124,38 @@ th a:hover {
.column-70 {
width: 70%;
-} \ No newline at end of file
+}
+
+.draggable-row-handle {
+ cursor: move;
+ color: #dedede;
+}
+
+.draggable-row-handle:hover {
+ color: #333;
+}
+
+tr.draggable-item-selected {
+ background: #fff;
+ border: 2px solid #666;
+ box-shadow: 4px 2px 10px -4px rgba(0,0,0,0.55);
+}
+
+tr.draggable-item-selected td {
+ border-top: none;
+ border-bottom: none;
+}
+
+tr.draggable-item-selected td:first-child {
+ border-left: none;
+}
+
+tr.draggable-item-selected td:last-child {
+ border-right: none;
+}
+
+.table-stripped tr.draggable-item-hover,
+tr.draggable-item-hover {
+ background: #FEFFF2;
+}
+
diff --git a/assets/css/src/task.css b/assets/css/src/task.css
index 7bfb63e2..ed0486e8 100644
--- a/assets/css/src/task.css
+++ b/assets/css/src/task.css
@@ -9,7 +9,7 @@
}
div.task-board-recent {
- box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.2);
+ border-width: 2px;
}
div.task-board-status-closed {
@@ -118,21 +118,11 @@ span.task-board-date-overdue {
}
/* task score */
-.task-score {
- font-weight: bold;
-}
-
.task-board .task-score {
+ font-weight: bold;
font-size: 1.1em;
}
-.task-show-details .task-score {
- position: absolute;
- bottom: 5px;
- right: 5px;
- font-size: 2em;
-}
-
/* task age */
.task-board-closed,
.task-board-days {
@@ -167,57 +157,41 @@ span.task-board-date-overdue {
display: none;
}
-/* task view */
-.task-show-details {
- position: relative;
- border-radius: 5px;
- padding-bottom: 10px;
-}
-
-.task-show-details h2 {
- font-size: 1.8em;
- margin: 0;
- margin-bottom: 25px;
- padding: 0;
- padding-left: 10px;
- padding-right: 10px;
-}
-
-.task-show-details li {
- margin-left: 25px;
- list-style-type: circle;
+/* task summary */
+#task-summary {
+ margin-bottom: 15px;
}
-.task-show-section {
- margin-top: 30px;
- margin-bottom: 20px;
+#task-summary h2 {
+ color: #666;
+ font-size: 2.5em;
+ margin-top: 0;
+ padding-top: 0;
}
-.task-show-files a {
- font-weight: bold;
- text-decoration: none;
-}
-
-.task-show-files li {
- margin-left: 25px;
- list-style-type: square;
- line-height: 25px;
+.task-summary-container {
+ border: 2px solid #000;
+ border-radius: 8px;
+ padding: 15px;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ flex-direction: row;
+ -webkit-justify-content: space-between;
+ justify-content: space-between;
}
-.task-show-file-actions {
- font-size: 0.75em;
-}
-
-.task-show-file-actions:before {
- content: " [";
+.task-summary-column {
+ font-size: 0.9em;
+ color: #666;
}
-.task-show-file-actions:after {
- content: "]";
+.task-summary-column span {
+ color: #555;
}
-.task-show-file-actions a {
- color: #333;
+.task-summary-column li {
+ line-height: 23px;
}
.task-show-description {
@@ -231,75 +205,10 @@ span.task-board-date-overdue {
height: 300px;
}
-.task-file-viewer {
- position: relative;
-}
-
-.task-file-viewer img {
- max-width: 95%;
- max-height: 85%;
- margin-top: 10px;
-}
-
-.task-time-form {
- margin-top: 10px;
- margin-bottom: 25px;
- padding: 3px;
-}
-
.task-link-closed {
text-decoration: line-through;
}
-.task-show-images {
- list-style-type: none;
-}
-
-.task-show-images li img {
- width: 100%;
-}
-
-.task-show-images li .img_container {
- width: 250px;
- height: 100px;
- overflow: hidden;
-}
-
-.task-show-images li {
- padding: 10px;
- overflow: auto;
- width: 250px;
- min-height: 120px;
- display: inline-block;
- vertical-align: top;
-}
-
-.task-show-images li p{
- padding: 5px;
- font-weight: bold;
-}
-
-.task-show-images li:hover {
- background: #eee;
-}
-
-.task-show-image-actions {
- margin-left: 5px;
-}
-
-.task-show-file-table {
- width: auto;
-}
-
-.task-show-start-link {
- color: #000;
-}
-
-.task-show-start-link:hover,
-.task-show-start-link:focus {
- color: red;
-}
-
.flag-milestone {
color: green;
}
@@ -329,3 +238,9 @@ div.color-square-selected {
height: 28px;
box-shadow: 3px 2px 10px 0 rgba(180,180,180,0.9);
}
+
+/* Assign to me */
+.assign-me {
+ font-size: 0.8em;
+ vertical-align: bottom;
+}
diff --git a/assets/css/src/tooltip.css b/assets/css/src/tooltip.css
index f74ac09a..84d709c9 100644
--- a/assets/css/src/tooltip.css
+++ b/assets/css/src/tooltip.css
@@ -76,3 +76,22 @@ div.ui-tooltip {
.ui-tooltip ul {
margin-left: 20px;
}
+
+.ui-tooltip dl {
+ margin: -5px 0 0 0;
+ padding: 0;
+}
+
+.ui-tooltip dt {
+ margin-top: 5px;
+}
+
+.ui-tooltip dd {
+ margin-left: 0;
+}
+
+.ui-tooltip .progress {
+ display: inline-block;
+ min-width: 3em;
+ text-align: right;
+} \ No newline at end of file
diff --git a/assets/css/src/upload.css b/assets/css/src/upload.css
new file mode 100644
index 00000000..aa46bc7a
--- /dev/null
+++ b/assets/css/src/upload.css
@@ -0,0 +1,39 @@
+#file-dropzone,
+#screenshot-zone {
+ position: relative;
+ border: 2px dashed #ccc;
+ width: 99%;
+ height: 250px;
+ overflow: auto;
+}
+
+#file-dropzone-inner,
+#screenshot-inner {
+ position: absolute;
+ left: 0;
+ bottom: 48%;
+ width: 100%;
+ text-align: center;
+ color: #aaa;
+}
+
+#screenshot-zone.screenshot-pasted {
+ border: 2px solid #333;
+}
+
+#file-list {
+ margin: 20px;
+}
+
+#file-list li {
+ list-style-type: none;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px dotted #ddd;
+ width: 95%;
+}
+
+#file-list li.file-error {
+ font-weight: bold;
+ color: #b94a48;
+}
diff --git a/assets/css/src/views.css b/assets/css/src/views.css
new file mode 100644
index 00000000..191b30c6
--- /dev/null
+++ b/assets/css/src/views.css
@@ -0,0 +1,34 @@
+.views {
+ display: inline-block;
+ margin-left: 10px;
+ margin-right: 10px;
+ font-size: 0.9em;
+}
+
+.views li {
+ border: 1px solid #eee;
+ padding-left: 8px;
+ padding-right: 8px;
+ padding-top: 5px;
+ padding-bottom: 5px;
+ display: inline;
+}
+
+.menu-inline li.active a,
+.views li.active a {
+ font-weight: bold;
+ color: #000;
+ text-decoration: none;
+}
+
+.views li:first-child {
+ border-right: none;
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+}
+
+.views li:last-child {
+ border-left: none;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
diff --git a/assets/css/vendor/font-awesome.min.css b/assets/css/vendor/font-awesome.min.css
index ee4e9782..d0603cb4 100644
--- a/assets/css/vendor/font-awesome.min.css
+++ b/assets/css/vendor/font-awesome.min.css
@@ -1,4 +1,4 @@
/*!
- * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
- */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.4.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.4.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.4.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.4.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.4.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.4.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}
+ */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.5.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}
diff --git a/assets/fonts/FontAwesome.otf b/assets/fonts/FontAwesome.otf
index 681bdd4d..3ed7f8b4 100644
--- a/assets/fonts/FontAwesome.otf
+++ b/assets/fonts/FontAwesome.otf
Binary files differ
diff --git a/assets/fonts/fontawesome-webfont.eot b/assets/fonts/fontawesome-webfont.eot
index a30335d7..9b6afaed 100644
--- a/assets/fonts/fontawesome-webfont.eot
+++ b/assets/fonts/fontawesome-webfont.eot
Binary files differ
diff --git a/assets/fonts/fontawesome-webfont.svg b/assets/fonts/fontawesome-webfont.svg
index 6fd19abc..d05688e9 100644
--- a/assets/fonts/fontawesome-webfont.svg
+++ b/assets/fonts/fontawesome-webfont.svg
@@ -1,6 +1,6 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
+<svg xmlns="http://www.w3.org/2000/svg">
<metadata></metadata>
<defs>
<font id="fontawesomeregular" horiz-adv-x="1536" >
@@ -219,8 +219,8 @@
<glyph unicode="&#xf0d1;" horiz-adv-x="1792" d="M640 128q0 52 -38 90t-90 38t-90 -38t-38 -90t38 -90t90 -38t90 38t38 90zM256 640h384v256h-158q-13 0 -22 -9l-195 -195q-9 -9 -9 -22v-30zM1536 128q0 52 -38 90t-90 38t-90 -38t-38 -90t38 -90t90 -38t90 38t38 90zM1792 1216v-1024q0 -15 -4 -26.5t-13.5 -18.5 t-16.5 -11.5t-23.5 -6t-22.5 -2t-25.5 0t-22.5 0.5q0 -106 -75 -181t-181 -75t-181 75t-75 181h-384q0 -106 -75 -181t-181 -75t-181 75t-75 181h-64q-3 0 -22.5 -0.5t-25.5 0t-22.5 2t-23.5 6t-16.5 11.5t-13.5 18.5t-4 26.5q0 26 19 45t45 19v320q0 8 -0.5 35t0 38 t2.5 34.5t6.5 37t14 30.5t22.5 30l198 198q19 19 50.5 32t58.5 13h160v192q0 26 19 45t45 19h1024q26 0 45 -19t19 -45z" />
<glyph unicode="&#xf0d2;" d="M1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103q-111 0 -218 32q59 93 78 164q9 34 54 211q20 -39 73 -67.5t114 -28.5q121 0 216 68.5t147 188.5t52 270q0 114 -59.5 214t-172.5 163t-255 63q-105 0 -196 -29t-154.5 -77t-109 -110.5t-67 -129.5t-21.5 -134 q0 -104 40 -183t117 -111q30 -12 38 20q2 7 8 31t8 30q6 23 -11 43q-51 61 -51 151q0 151 104.5 259.5t273.5 108.5q151 0 235.5 -82t84.5 -213q0 -170 -68.5 -289t-175.5 -119q-61 0 -98 43.5t-23 104.5q8 35 26.5 93.5t30 103t11.5 75.5q0 50 -27 83t-77 33 q-62 0 -105 -57t-43 -142q0 -73 25 -122l-99 -418q-17 -70 -13 -177q-206 91 -333 281t-127 423q0 209 103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" />
<glyph unicode="&#xf0d3;" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-725q85 122 108 210q9 34 53 209q21 -39 73.5 -67t112.5 -28q181 0 295.5 147.5t114.5 373.5q0 84 -35 162.5t-96.5 139t-152.5 97t-197 36.5q-104 0 -194.5 -28.5t-153 -76.5 t-107.5 -109.5t-66.5 -128t-21.5 -132.5q0 -102 39.5 -180t116.5 -110q13 -5 23.5 0t14.5 19q10 44 15 61q6 23 -11 42q-50 62 -50 150q0 150 103.5 256.5t270.5 106.5q149 0 232.5 -81t83.5 -210q0 -168 -67.5 -286t-173.5 -118q-60 0 -97 43.5t-23 103.5q8 34 26.5 92.5 t29.5 102t11 74.5q0 49 -26.5 81.5t-75.5 32.5q-61 0 -103.5 -56.5t-42.5 -139.5q0 -72 24 -121l-98 -414q-24 -100 -7 -254h-183q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960z" />
-<glyph unicode="&#xf0d4;" d="M829 318q0 -76 -58.5 -112.5t-139.5 -36.5q-41 0 -80.5 9.5t-75.5 28.5t-58 53t-22 78q0 46 25 80t65.5 51.5t82 25t84.5 7.5q20 0 31 -2q2 -1 23 -16.5t26 -19t23 -18t24.5 -22t19 -22.5t17 -26t9 -26.5t4.5 -31.5zM755 863q0 -60 -33 -99.5t-92 -39.5q-53 0 -93 42.5 t-57.5 96.5t-17.5 106q0 61 32 104t92 43q53 0 93.5 -45t58 -101t17.5 -107zM861 1120l88 64h-265q-85 0 -161 -32t-127.5 -98t-51.5 -153q0 -93 64.5 -154.5t158.5 -61.5q22 0 43 3q-13 -29 -13 -54q0 -44 40 -94q-175 -12 -257 -63q-47 -29 -75.5 -73t-28.5 -95 q0 -43 18.5 -77.5t48.5 -56.5t69 -37t77.5 -21t76.5 -6q60 0 120.5 15.5t113.5 46t86 82.5t33 117q0 49 -20 89.5t-49 66.5t-58 47.5t-49 44t-20 44.5t15.5 42.5t37.5 39.5t44 42t37.5 59.5t15.5 82.5q0 60 -22.5 99.5t-72.5 90.5h83zM1152 672h128v64h-128v128h-64v-128 h-128v-64h128v-160h64v160zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" />
-<glyph unicode="&#xf0d5;" horiz-adv-x="1664" d="M735 740q0 -36 32 -70.5t77.5 -68t90.5 -73.5t77 -104t32 -142q0 -90 -48 -173q-72 -122 -211 -179.5t-298 -57.5q-132 0 -246.5 41.5t-171.5 137.5q-37 60 -37 131q0 81 44.5 150t118.5 115q131 82 404 100q-32 42 -47.5 74t-15.5 73q0 36 21 85q-46 -4 -68 -4 q-148 0 -249.5 96.5t-101.5 244.5q0 82 36 159t99 131q77 66 182.5 98t217.5 32h418l-138 -88h-131q74 -63 112 -133t38 -160q0 -72 -24.5 -129.5t-59 -93t-69.5 -65t-59.5 -61.5t-24.5 -66zM589 836q38 0 78 16.5t66 43.5q53 57 53 159q0 58 -17 125t-48.5 129.5 t-84.5 103.5t-117 41q-42 0 -82.5 -19.5t-65.5 -52.5q-47 -59 -47 -160q0 -46 10 -97.5t31.5 -103t52 -92.5t75 -67t96.5 -26zM591 -37q58 0 111.5 13t99 39t73 73t27.5 109q0 25 -7 49t-14.5 42t-27 41.5t-29.5 35t-38.5 34.5t-36.5 29t-41.5 30t-36.5 26q-16 2 -48 2 q-53 0 -105 -7t-107.5 -25t-97 -46t-68.5 -74.5t-27 -105.5q0 -70 35 -123.5t91.5 -83t119 -44t127.5 -14.5zM1401 839h213v-108h-213v-219h-105v219h-212v108h212v217h105v-217z" />
+<glyph unicode="&#xf0d4;" d="M917 631q0 26 -6 64h-362v-132h217q-3 -24 -16.5 -50t-37.5 -53t-66.5 -44.5t-96.5 -17.5q-99 0 -169 71t-70 171t70 171t169 71q92 0 153 -59l104 101q-108 100 -257 100q-160 0 -272 -112.5t-112 -271.5t112 -271.5t272 -112.5q165 0 266.5 105t101.5 270zM1262 585 h109v110h-109v110h-110v-110h-110v-110h110v-110h110v110zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" />
+<glyph unicode="&#xf0d5;" horiz-adv-x="2304" d="M1437 623q0 -208 -87 -370.5t-248 -254t-369 -91.5q-149 0 -285 58t-234 156t-156 234t-58 285t58 285t156 234t234 156t285 58q286 0 491 -192l-199 -191q-117 113 -292 113q-123 0 -227.5 -62t-165.5 -168.5t-61 -232.5t61 -232.5t165.5 -168.5t227.5 -62 q83 0 152.5 23t114.5 57.5t78.5 78.5t49 83t21.5 74h-416v252h692q12 -63 12 -122zM2304 745v-210h-209v-209h-210v209h-209v210h209v209h210v-209h209z" />
<glyph unicode="&#xf0d6;" horiz-adv-x="1920" d="M768 384h384v96h-128v448h-114l-148 -137l77 -80q42 37 55 57h2v-288h-128v-96zM1280 640q0 -70 -21 -142t-59.5 -134t-101.5 -101t-138 -39t-138 39t-101.5 101t-59.5 134t-21 142t21 142t59.5 134t101.5 101t138 39t138 -39t101.5 -101t59.5 -134t21 -142zM1792 384 v512q-106 0 -181 75t-75 181h-1152q0 -106 -75 -181t-181 -75v-512q106 0 181 -75t75 -181h1152q0 106 75 181t181 75zM1920 1216v-1152q0 -26 -19 -45t-45 -19h-1792q-26 0 -45 19t-19 45v1152q0 26 19 45t45 19h1792q26 0 45 -19t19 -45z" />
<glyph unicode="&#xf0d7;" horiz-adv-x="1024" d="M1024 832q0 -26 -19 -45l-448 -448q-19 -19 -45 -19t-45 19l-448 448q-19 19 -19 45t19 45t45 19h896q26 0 45 -19t19 -45z" />
<glyph unicode="&#xf0d8;" horiz-adv-x="1024" d="M1024 320q0 -26 -19 -45t-45 -19h-896q-26 0 -45 19t-19 45t19 45l448 448q19 19 45 19t45 -19l448 -448q19 -19 19 -45z" />
@@ -362,7 +362,7 @@
<glyph unicode="&#xf169;" d="M685 771q0 1 -126 222q-21 34 -52 34h-184q-18 0 -26 -11q-7 -12 1 -29l125 -216v-1l-196 -346q-9 -14 0 -28q8 -13 24 -13h185q31 0 50 36zM1309 1268q-7 12 -24 12h-187q-30 0 -49 -35l-411 -729q1 -2 262 -481q20 -35 52 -35h184q18 0 25 12q8 13 -1 28l-260 476v1 l409 723q8 16 0 28zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" />
<glyph unicode="&#xf16a;" horiz-adv-x="1792" d="M1280 640q0 37 -30 54l-512 320q-31 20 -65 2q-33 -18 -33 -56v-640q0 -38 33 -56q16 -8 31 -8q20 0 34 10l512 320q30 17 30 54zM1792 640q0 -96 -1 -150t-8.5 -136.5t-22.5 -147.5q-16 -73 -69 -123t-124 -58q-222 -25 -671 -25t-671 25q-71 8 -124.5 58t-69.5 123 q-14 65 -21.5 147.5t-8.5 136.5t-1 150t1 150t8.5 136.5t22.5 147.5q16 73 69 123t124 58q222 25 671 25t671 -25q71 -8 124.5 -58t69.5 -123q14 -65 21.5 -147.5t8.5 -136.5t1 -150z" />
<glyph unicode="&#xf16b;" horiz-adv-x="1792" d="M402 829l494 -305l-342 -285l-490 319zM1388 274v-108l-490 -293v-1l-1 1l-1 -1v1l-489 293v108l147 -96l342 284v2l1 -1l1 1v-2l343 -284zM554 1418l342 -285l-494 -304l-338 270zM1390 829l338 -271l-489 -319l-343 285zM1239 1418l489 -319l-338 -270l-494 304z" />
-<glyph unicode="&#xf16c;" horiz-adv-x="1408" d="M928 135v-151l-707 -1v151zM1169 481v-701l-1 -35v-1h-1132l-35 1h-1v736h121v-618h928v618h120zM241 393l704 -65l-13 -150l-705 65zM309 709l683 -183l-39 -146l-683 183zM472 1058l609 -360l-77 -130l-609 360zM832 1389l398 -585l-124 -85l-399 584zM1285 1536 l121 -697l-149 -26l-121 697z" />
+<glyph unicode="&#xf16c;" d="M1289 -96h-1118v480h-160v-640h1438v640h-160v-480zM347 428l33 157l783 -165l-33 -156zM450 802l67 146l725 -339l-67 -145zM651 1158l102 123l614 -513l-102 -123zM1048 1536l477 -641l-128 -96l-477 641zM330 65v159h800v-159h-800z" />
<glyph unicode="&#xf16d;" d="M1362 110v648h-135q20 -63 20 -131q0 -126 -64 -232.5t-174 -168.5t-240 -62q-197 0 -337 135.5t-140 327.5q0 68 20 131h-141v-648q0 -26 17.5 -43.5t43.5 -17.5h1069q25 0 43 17.5t18 43.5zM1078 643q0 124 -90.5 211.5t-218.5 87.5q-127 0 -217.5 -87.5t-90.5 -211.5 t90.5 -211.5t217.5 -87.5q128 0 218.5 87.5t90.5 211.5zM1362 1003v165q0 28 -20 48.5t-49 20.5h-174q-29 0 -49 -20.5t-20 -48.5v-165q0 -29 20 -49t49 -20h174q29 0 49 20t20 49zM1536 1211v-1142q0 -81 -58 -139t-139 -58h-1142q-81 0 -139 58t-58 139v1142q0 81 58 139 t139 58h1142q81 0 139 -58t58 -139z" />
<glyph unicode="&#xf16e;" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960zM698 640q0 88 -62 150t-150 62t-150 -62t-62 -150t62 -150t150 -62t150 62t62 150zM1262 640q0 88 -62 150 t-150 62t-150 -62t-62 -150t62 -150t150 -62t150 62t62 150z" />
<glyph unicode="&#xf170;" d="M768 914l201 -306h-402zM1133 384h94l-459 691l-459 -691h94l104 160h522zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" />
@@ -410,7 +410,7 @@
<glyph unicode="&#xf19c;" horiz-adv-x="2048" d="M960 1536l960 -384v-128h-128q0 -26 -20.5 -45t-48.5 -19h-1526q-28 0 -48.5 19t-20.5 45h-128v128zM256 896h256v-768h128v768h256v-768h128v768h256v-768h128v768h256v-768h59q28 0 48.5 -19t20.5 -45v-64h-1664v64q0 26 20.5 45t48.5 19h59v768zM1851 -64 q28 0 48.5 -19t20.5 -45v-128h-1920v128q0 26 20.5 45t48.5 19h1782z" />
<glyph unicode="&#xf19d;" horiz-adv-x="2304" d="M1774 700l18 -316q4 -69 -82 -128t-235 -93.5t-323 -34.5t-323 34.5t-235 93.5t-82 128l18 316l574 -181q22 -7 48 -7t48 7zM2304 1024q0 -23 -22 -31l-1120 -352q-4 -1 -10 -1t-10 1l-652 206q-43 -34 -71 -111.5t-34 -178.5q63 -36 63 -109q0 -69 -58 -107l58 -433 q2 -14 -8 -25q-9 -11 -24 -11h-192q-15 0 -24 11q-10 11 -8 25l58 433q-58 38 -58 107q0 73 65 111q11 207 98 330l-333 104q-22 8 -22 31t22 31l1120 352q4 1 10 1t10 -1l1120 -352q22 -8 22 -31z" />
<glyph unicode="&#xf19e;" d="M859 579l13 -707q-62 11 -105 11q-41 0 -105 -11l13 707q-40 69 -168.5 295.5t-216.5 374.5t-181 287q58 -15 108 -15q43 0 111 15q63 -111 133.5 -229.5t167 -276.5t138.5 -227q37 61 109.5 177.5t117.5 190t105 176t107 189.5q54 -14 107 -14q56 0 114 14v0 q-28 -39 -60 -88.5t-49.5 -78.5t-56.5 -96t-49 -84q-146 -248 -353 -610z" />
-<glyph unicode="&#xf1a0;" horiz-adv-x="1280" d="M981 197q0 25 -7 49t-14.5 42t-27 41.5t-29.5 35t-38.5 34.5t-36.5 29t-41.5 30t-36.5 26q-16 2 -49 2q-53 0 -104.5 -7t-107 -25t-97 -46t-68.5 -74.5t-27 -105.5q0 -56 23.5 -102t61 -75.5t87 -50t100 -29t101.5 -8.5q58 0 111.5 13t99 39t73 73t27.5 109zM864 1055 q0 59 -17 125.5t-48 129t-84 103.5t-117 41q-42 0 -82.5 -19.5t-66.5 -52.5q-46 -59 -46 -160q0 -46 10 -97.5t31.5 -103t52 -92.5t75 -67t96.5 -26q37 0 77.5 16.5t65.5 43.5q53 56 53 159zM752 1536h417l-137 -88h-132q75 -63 113 -133t38 -160q0 -72 -24.5 -129.5 t-59.5 -93t-69.5 -65t-59 -61.5t-24.5 -66q0 -36 32 -70.5t77 -68t90.5 -73.5t77.5 -104t32 -142q0 -91 -49 -173q-71 -122 -209.5 -179.5t-298.5 -57.5q-132 0 -246.5 41.5t-172.5 137.5q-36 59 -36 131q0 81 44.5 150t118.5 115q131 82 404 100q-32 41 -47.5 73.5 t-15.5 73.5q0 40 21 85q-46 -4 -68 -4q-148 0 -249.5 96.5t-101.5 244.5q0 82 36 159t99 131q76 66 182 98t218 32z" />
+<glyph unicode="&#xf1a0;" d="M768 750h725q12 -67 12 -128q0 -217 -91 -387.5t-259.5 -266.5t-386.5 -96q-157 0 -299 60.5t-245 163.5t-163.5 245t-60.5 299t60.5 299t163.5 245t245 163.5t299 60.5q300 0 515 -201l-209 -201q-123 119 -306 119q-129 0 -238.5 -65t-173.5 -176.5t-64 -243.5 t64 -243.5t173.5 -176.5t238.5 -65q87 0 160 24t120 60t82 82t51.5 87t22.5 78h-436v264z" />
<glyph unicode="&#xf1a1;" horiz-adv-x="1792" d="M1095 369q16 -16 0 -31q-62 -62 -199 -62t-199 62q-16 15 0 31q6 6 15 6t15 -6q48 -49 169 -49q120 0 169 49q6 6 15 6t15 -6zM788 550q0 -37 -26 -63t-63 -26t-63.5 26t-26.5 63q0 38 26.5 64t63.5 26t63 -26.5t26 -63.5zM1183 550q0 -37 -26.5 -63t-63.5 -26t-63 26 t-26 63t26 63.5t63 26.5t63.5 -26t26.5 -64zM1434 670q0 49 -35 84t-85 35t-86 -36q-130 90 -311 96l63 283l200 -45q0 -37 26 -63t63 -26t63.5 26.5t26.5 63.5t-26.5 63.5t-63.5 26.5q-54 0 -80 -50l-221 49q-19 5 -25 -16l-69 -312q-180 -7 -309 -97q-35 37 -87 37 q-50 0 -85 -35t-35 -84q0 -35 18.5 -64t49.5 -44q-6 -27 -6 -56q0 -142 140 -243t337 -101q198 0 338 101t140 243q0 32 -7 57q30 15 48 43.5t18 63.5zM1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191 t348 71t348 -71t286 -191t191 -286t71 -348z" />
<glyph unicode="&#xf1a2;" d="M939 407q13 -13 0 -26q-53 -53 -171 -53t-171 53q-13 13 0 26q5 6 13 6t13 -6q42 -42 145 -42t145 42q5 6 13 6t13 -6zM676 563q0 -31 -23 -54t-54 -23t-54 23t-23 54q0 32 22.5 54.5t54.5 22.5t54.5 -22.5t22.5 -54.5zM1014 563q0 -31 -23 -54t-54 -23t-54 23t-23 54 q0 32 22.5 54.5t54.5 22.5t54.5 -22.5t22.5 -54.5zM1229 666q0 42 -30 72t-73 30q-42 0 -73 -31q-113 78 -267 82l54 243l171 -39q1 -32 23.5 -54t53.5 -22q32 0 54.5 22.5t22.5 54.5t-22.5 54.5t-54.5 22.5q-48 0 -69 -43l-189 42q-17 5 -21 -13l-60 -268q-154 -6 -265 -83 q-30 32 -74 32q-43 0 -73 -30t-30 -72q0 -30 16 -55t42 -38q-5 -25 -5 -48q0 -122 120 -208.5t289 -86.5q170 0 290 86.5t120 208.5q0 25 -6 49q25 13 40.5 37.5t15.5 54.5zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960 q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" />
<glyph unicode="&#xf1a3;" d="M866 697l90 27v62q0 79 -58 135t-138 56t-138 -55.5t-58 -134.5v-283q0 -20 -14 -33.5t-33 -13.5t-32.5 13.5t-13.5 33.5v120h-151v-122q0 -82 57.5 -139t139.5 -57q81 0 138.5 56.5t57.5 136.5v280q0 19 13.5 33t33.5 14q19 0 32.5 -14t13.5 -33v-54zM1199 502v122h-150 v-126q0 -20 -13.5 -33.5t-33.5 -13.5q-19 0 -32.5 14t-13.5 33v123l-90 -26l-60 28v-123q0 -80 58 -137t139 -57t138.5 57t57.5 139zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103 t385.5 -103t279.5 -279.5t103 -385.5z" />
@@ -454,7 +454,7 @@
<glyph unicode="&#xf1cb;" horiz-adv-x="1792" d="M216 367l603 -402v359l-334 223zM154 511l193 129l-193 129v-258zM973 -35l603 402l-269 180l-334 -223v-359zM896 458l272 182l-272 182l-272 -182zM485 733l334 223v359l-603 -402zM1445 640l193 -129v258zM1307 733l269 180l-603 402v-359zM1792 913v-546 q0 -41 -34 -64l-819 -546q-21 -13 -43 -13t-43 13l-819 546q-34 23 -34 64v546q0 41 34 64l819 546q21 13 43 13t43 -13l819 -546q34 -23 34 -64z" />
<glyph unicode="&#xf1cc;" horiz-adv-x="2048" d="M1800 764q111 -46 179.5 -145.5t68.5 -221.5q0 -164 -118 -280.5t-285 -116.5q-4 0 -11.5 0.5t-10.5 0.5h-1209h-1h-2h-5q-170 10 -288 125.5t-118 280.5q0 110 55 203t147 147q-12 39 -12 82q0 115 82 196t199 81q95 0 172 -58q75 154 222.5 248t326.5 94 q166 0 306 -80.5t221.5 -218.5t81.5 -301q0 -6 -0.5 -18t-0.5 -18zM468 498q0 -122 84 -193t208 -71q137 0 240 99q-16 20 -47.5 56.5t-43.5 50.5q-67 -65 -144 -65q-55 0 -93.5 33.5t-38.5 87.5q0 53 38.5 87t91.5 34q44 0 84.5 -21t73 -55t65 -75t69 -82t77 -75t97 -55 t121.5 -21q121 0 204.5 71.5t83.5 190.5q0 121 -84 192t-207 71q-143 0 -241 -97q14 -16 29.5 -34t34.5 -40t29 -34q66 64 142 64q52 0 92 -33t40 -84q0 -57 -37 -91.5t-94 -34.5q-43 0 -82.5 21t-72 55t-65.5 75t-69.5 82t-77.5 75t-96.5 55t-118.5 21q-122 0 -207 -70.5 t-85 -189.5z" />
<glyph unicode="&#xf1cd;" horiz-adv-x="1792" d="M896 1536q182 0 348 -71t286 -191t191 -286t71 -348t-71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71zM896 1408q-190 0 -361 -90l194 -194q82 28 167 28t167 -28l194 194q-171 90 -361 90zM218 279l194 194 q-28 82 -28 167t28 167l-194 194q-90 -171 -90 -361t90 -361zM896 -128q190 0 361 90l-194 194q-82 -28 -167 -28t-167 28l-194 -194q171 -90 361 -90zM896 256q159 0 271.5 112.5t112.5 271.5t-112.5 271.5t-271.5 112.5t-271.5 -112.5t-112.5 -271.5t112.5 -271.5 t271.5 -112.5zM1380 473l194 -194q90 171 90 361t-90 361l-194 -194q28 -82 28 -167t-28 -167z" />
-<glyph unicode="&#xf1ce;" horiz-adv-x="1792" d="M1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348q0 222 101 414.5t276.5 317t390.5 155.5v-260q-221 -45 -366.5 -221t-145.5 -406q0 -130 51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5 q0 230 -145.5 406t-366.5 221v260q215 -31 390.5 -155.5t276.5 -317t101 -414.5z" />
+<glyph unicode="&#xf1ce;" horiz-adv-x="1792" d="M1760 640q0 -176 -68.5 -336t-184 -275.5t-275.5 -184t-336 -68.5t-336 68.5t-275.5 184t-184 275.5t-68.5 336q0 213 97 398.5t265 305.5t374 151v-228q-221 -45 -366.5 -221t-145.5 -406q0 -130 51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5 t136.5 204t51 248.5q0 230 -145.5 406t-366.5 221v228q206 -31 374 -151t265 -305.5t97 -398.5z" />
<glyph unicode="&#xf1d0;" horiz-adv-x="1792" d="M19 662q8 217 116 406t305 318h5q0 -1 -1 -3q-8 -8 -28 -33.5t-52 -76.5t-60 -110.5t-44.5 -135.5t-14 -150.5t39 -157.5t108.5 -154q50 -50 102 -69.5t90.5 -11.5t69.5 23.5t47 32.5l16 16q39 51 53 116.5t6.5 122.5t-21 107t-26.5 80l-14 29q-10 25 -30.5 49.5t-43 41 t-43.5 29.5t-35 19l-13 6l104 115q39 -17 78 -52t59 -61l19 -27q1 48 -18.5 103.5t-40.5 87.5l-20 31l161 183l160 -181q-33 -46 -52.5 -102.5t-22.5 -90.5l-4 -33q22 37 61.5 72.5t67.5 52.5l28 17l103 -115q-44 -14 -85 -50t-60 -65l-19 -29q-31 -56 -48 -133.5t-7 -170 t57 -156.5q33 -45 77.5 -60.5t85 -5.5t76 26.5t57.5 33.5l21 16q60 53 96.5 115t48.5 121.5t10 121.5t-18 118t-37 107.5t-45.5 93t-45 72t-34.5 47.5l-13 17q-14 13 -7 13l10 -3q40 -29 62.5 -46t62 -50t64 -58t58.5 -65t55.5 -77t45.5 -88t38 -103t23.5 -117t10.5 -136 q3 -259 -108 -465t-312 -321t-456 -115q-185 0 -351 74t-283.5 198t-184 293t-60.5 353z" />
<glyph unicode="&#xf1d1;" horiz-adv-x="1792" d="M874 -102v-66q-208 6 -385 109.5t-283 275.5l58 34q29 -49 73 -99l65 57q148 -168 368 -212l-17 -86q65 -12 121 -13zM276 428l-83 -28q22 -60 49 -112l-57 -33q-98 180 -98 385t98 385l57 -33q-30 -56 -49 -112l82 -28q-35 -100 -35 -212q0 -109 36 -212zM1528 251 l58 -34q-106 -172 -283 -275.5t-385 -109.5v66q56 1 121 13l-17 86q220 44 368 212l65 -57q44 50 73 99zM1377 805l-233 -80q14 -42 14 -85t-14 -85l232 -80q-31 -92 -98 -169l-185 162q-57 -67 -147 -85l48 -241q-52 -10 -98 -10t-98 10l48 241q-90 18 -147 85l-185 -162 q-67 77 -98 169l232 80q-14 42 -14 85t14 85l-233 80q33 93 99 169l185 -162q59 68 147 86l-48 240q44 10 98 10t98 -10l-48 -240q88 -18 147 -86l185 162q66 -76 99 -169zM874 1448v-66q-65 -2 -121 -13l17 -86q-220 -42 -368 -211l-65 56q-38 -42 -73 -98l-57 33 q106 172 282 275.5t385 109.5zM1705 640q0 -205 -98 -385l-57 33q27 52 49 112l-83 28q36 103 36 212q0 112 -35 212l82 28q-19 56 -49 112l57 33q98 -180 98 -385zM1585 1063l-57 -33q-35 56 -73 98l-65 -56q-148 169 -368 211l17 86q-56 11 -121 13v66q209 -6 385 -109.5 t282 -275.5zM1748 640q0 173 -67.5 331t-181.5 272t-272 181.5t-331 67.5t-331 -67.5t-272 -181.5t-181.5 -272t-67.5 -331t67.5 -331t181.5 -272t272 -181.5t331 -67.5t331 67.5t272 181.5t181.5 272t67.5 331zM1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71 t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71t348 -71t286 -191t191 -286t71 -348z" />
<glyph unicode="&#xf1d2;" d="M582 228q0 -66 -93 -66q-107 0 -107 63q0 64 98 64q102 0 102 -61zM546 694q0 -85 -74 -85q-77 0 -77 84q0 90 77 90q36 0 55 -25.5t19 -63.5zM712 769v125q-78 -29 -135 -29q-50 29 -110 29q-86 0 -145 -57t-59 -143q0 -50 29.5 -102t73.5 -67v-3q-38 -17 -38 -85 q0 -53 41 -77v-3q-113 -37 -113 -139q0 -45 20 -78.5t54 -51t72 -25.5t81 -8q224 0 224 188q0 67 -48 99t-126 46q-27 5 -51.5 20.5t-24.5 39.5q0 44 49 52q77 15 122 70t45 134q0 24 -10 52q37 9 49 13zM771 350h137q-2 27 -2 82v387q0 46 2 69h-137q3 -23 3 -71v-392 q0 -50 -3 -75zM1280 366v121q-30 -21 -68 -21q-53 0 -53 82v225h52q9 0 26.5 -1t26.5 -1v117h-105q0 82 3 102h-140q4 -24 4 -55v-47h-60v-117q36 3 37 3q3 0 11 -0.5t12 -0.5v-2h-2v-217q0 -37 2.5 -64t11.5 -56.5t24.5 -48.5t43.5 -31t66 -12q64 0 108 24zM924 1072 q0 36 -24 63.5t-60 27.5t-60.5 -27t-24.5 -64q0 -36 25 -62.5t60 -26.5t59.5 27t24.5 62zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" />
@@ -555,7 +555,7 @@
<glyph unicode="&#xf237;" d="M1536 1536l-192 -448h192v-192h-274l-55 -128h329v-192h-411l-357 -832l-357 832h-411v192h329l-55 128h-274v192h192l-192 448h256l323 -768h378l323 768h256zM768 320l108 256h-216z" />
<glyph unicode="&#xf238;" d="M1088 1536q185 0 316.5 -93.5t131.5 -226.5v-896q0 -130 -125.5 -222t-305.5 -97l213 -202q16 -15 8 -35t-30 -20h-1056q-22 0 -30 20t8 35l213 202q-180 5 -305.5 97t-125.5 222v896q0 133 131.5 226.5t316.5 93.5h640zM768 192q80 0 136 56t56 136t-56 136t-136 56 t-136 -56t-56 -136t56 -136t136 -56zM1344 768v512h-1152v-512h1152z" />
<glyph unicode="&#xf239;" d="M1088 1536q185 0 316.5 -93.5t131.5 -226.5v-896q0 -130 -125.5 -222t-305.5 -97l213 -202q16 -15 8 -35t-30 -20h-1056q-22 0 -30 20t8 35l213 202q-180 5 -305.5 97t-125.5 222v896q0 133 131.5 226.5t316.5 93.5h640zM288 224q66 0 113 47t47 113t-47 113t-113 47 t-113 -47t-47 -113t47 -113t113 -47zM704 768v512h-544v-512h544zM1248 224q66 0 113 47t47 113t-47 113t-113 47t-113 -47t-47 -113t47 -113t113 -47zM1408 768v512h-576v-512h576z" />
-<glyph unicode="&#xf23a;" horiz-adv-x="1792" d="M1792 204v-209h-642v209h134v926h-6l-314 -1135h-243l-310 1135h-8v-926h135v-209h-538v209h69q21 0 43 19.5t22 37.5v881q0 18 -22 40t-43 22h-69v209h672l221 -821h6l223 821h670v-209h-71q-19 0 -41 -22t-22 -40v-881q0 -18 21.5 -37.5t41.5 -19.5h71z" />
+<glyph unicode="&#xf23a;" horiz-adv-x="1792" d="M597 1115v-1173q0 -25 -12.5 -42.5t-36.5 -17.5q-17 0 -33 8l-465 233q-21 10 -35.5 33.5t-14.5 46.5v1140q0 20 10 34t29 14q14 0 44 -15l511 -256q3 -3 3 -5zM661 1014l534 -866l-534 266v600zM1792 996v-1054q0 -25 -14 -40.5t-38 -15.5t-47 13l-441 220zM1789 1116 q0 -3 -256.5 -419.5t-300.5 -487.5l-390 634l324 527q17 28 52 28q14 0 26 -6l541 -270q4 -2 4 -6z" />
<glyph unicode="&#xf23b;" d="M809 532l266 499h-112l-157 -312q-24 -48 -44 -92l-42 92l-155 312h-120l263 -493v-324h101v318zM1536 1408v-1536h-1536v1536h1536z" />
<glyph unicode="&#xf23c;" horiz-adv-x="2296" d="M478 -139q-8 -16 -27 -34.5t-37 -25.5q-25 -9 -51.5 3.5t-28.5 31.5q-1 22 40 55t68 38q23 4 34 -21.5t2 -46.5zM1819 -139q7 -16 26 -34.5t38 -25.5q25 -9 51.5 3.5t27.5 31.5q2 22 -39.5 55t-68.5 38q-22 4 -33 -21.5t-2 -46.5zM1867 -30q13 -27 56.5 -59.5t77.5 -41.5 q45 -13 82 4.5t37 50.5q0 46 -67.5 100.5t-115.5 59.5q-40 5 -63.5 -37.5t-6.5 -76.5zM428 -30q-13 -27 -56 -59.5t-77 -41.5q-45 -13 -82 4.5t-37 50.5q0 46 67.5 100.5t115.5 59.5q40 5 63 -37.5t6 -76.5zM1158 1094h1q-41 0 -76 -15q27 -8 44 -30.5t17 -49.5 q0 -35 -27 -60t-65 -25q-52 0 -80 43q-5 -23 -5 -42q0 -74 56 -126.5t135 -52.5q80 0 136 52.5t56 126.5t-56 126.5t-136 52.5zM1462 1312q-99 109 -220.5 131.5t-245.5 -44.5q27 60 82.5 96.5t118 39.5t121.5 -17t99.5 -74.5t44.5 -131.5zM2212 73q8 -11 -11 -42 q7 -23 7 -40q1 -56 -44.5 -112.5t-109.5 -91.5t-118 -37q-48 -2 -92 21.5t-66 65.5q-687 -25 -1259 0q-23 -41 -66.5 -65t-92.5 -22q-86 3 -179.5 80.5t-92.5 160.5q2 22 7 40q-19 31 -11 42q6 10 31 1q14 22 41 51q-7 29 2 38q11 10 39 -4q29 20 59 34q0 29 13 37 q23 12 51 -16q35 5 61 -2q18 -4 38 -19v73q-11 0 -18 2q-53 10 -97 44.5t-55 87.5q-9 38 0 81q15 62 93 95q2 17 19 35.5t36 23.5t33 -7.5t19 -30.5h13q46 -5 60 -23q3 -3 5 -7q10 1 30.5 3.5t30.5 3.5q-15 11 -30 17q-23 40 -91 43q0 6 1 10q-62 2 -118.5 18.5t-84.5 47.5 q-32 36 -42.5 92t-2.5 112q16 126 90 179q23 16 52 4.5t32 -40.5q0 -1 1.5 -14t2.5 -21t3 -20t5.5 -19t8.5 -10q27 -14 76 -12q48 46 98 74q-40 4 -162 -14l47 46q61 58 163 111q145 73 282 86q-20 8 -41 15.5t-47 14t-42.5 10.5t-47.5 11t-43 10q595 126 904 -139 q98 -84 158 -222q85 -10 121 9h1q5 3 8.5 10t5.5 19t3 19.5t3 21.5l1 14q3 28 32 40t52 -5q73 -52 91 -178q7 -57 -3.5 -113t-42.5 -91q-28 -32 -83.5 -48.5t-115.5 -18.5v-10q-71 -2 -95 -43q-14 -5 -31 -17q11 -1 32 -3.5t30 -3.5q1 4 5 8q16 18 60 23h13q5 18 19 30t33 8 t36 -23t19 -36q79 -32 93 -95q9 -40 1 -81q-12 -53 -56 -88t-97 -44q-10 -2 -17 -2q0 -49 -1 -73q20 15 38 19q26 7 61 2q28 28 51 16q14 -9 14 -37q33 -16 59 -34q27 13 38 4q10 -10 2 -38q28 -30 41 -51q23 8 31 -1zM1937 1025q0 -29 -9 -54q82 -32 112 -132 q4 37 -9.5 98.5t-41.5 90.5q-20 19 -36 17t-16 -20zM1859 925q35 -42 47.5 -108.5t-0.5 -124.5q67 13 97 45q13 14 18 28q-3 64 -31 114.5t-79 66.5q-15 -15 -52 -21zM1822 921q-30 0 -44 1q42 -115 53 -239q21 0 43 3q16 68 1 135t-53 100zM258 839q30 100 112 132 q-9 25 -9 54q0 18 -16.5 20t-35.5 -17q-28 -29 -41.5 -90.5t-9.5 -98.5zM294 737q29 -31 97 -45q-13 58 -0.5 124.5t47.5 108.5v0q-37 6 -52 21q-51 -16 -78.5 -66t-31.5 -115q9 -17 18 -28zM471 683q14 124 73 235q-19 -4 -55 -18l-45 -19v1q-46 -89 -20 -196q25 -3 47 -3z M1434 644q8 -38 16.5 -108.5t11.5 -89.5q3 -18 9.5 -21.5t23.5 4.5q40 20 62 85.5t23 125.5q-24 2 -146 4zM1152 1285q-116 0 -199 -82.5t-83 -198.5q0 -117 83 -199.5t199 -82.5t199 82.5t83 199.5q0 116 -83 198.5t-199 82.5zM1380 646q-106 2 -211 0v1q-1 -27 2.5 -86 t13.5 -66q29 -14 93.5 -14.5t95.5 10.5q9 3 11 39t-0.5 69.5t-4.5 46.5zM1112 447q8 4 9.5 48t-0.5 88t-4 63v1q-212 -3 -214 -3q-4 -20 -7 -62t0 -83t14 -46q34 -15 101 -16t101 10zM718 636q-16 -59 4.5 -118.5t77.5 -84.5q15 -8 24 -5t12 21q3 16 8 90t10 103 q-69 -2 -136 -6zM591 510q3 -23 -34 -36q132 -141 271.5 -240t305.5 -154q172 49 310.5 146t293.5 250q-33 13 -30 34l3 9v1v-1q-17 2 -50 5.5t-48 4.5q-26 -90 -82 -132q-51 -38 -82 1q-5 6 -9 14q-7 13 -17 62q-2 -5 -5 -9t-7.5 -7t-8 -5.5t-9.5 -4l-10 -2.5t-12 -2 l-12 -1.5t-13.5 -1t-13.5 -0.5q-106 -9 -163 11q-4 -17 -10 -26.5t-21 -15t-23 -7t-36 -3.5q-2 0 -3 -0.5t-3 -0.5h-3q-179 -17 -203 40q-2 -63 -56 -54q-47 8 -91 54q-12 13 -20 26q-17 29 -26 65q-58 -6 -87 -10q1 -2 4 -10zM507 -118q3 14 3 30q-17 71 -51 130t-73 70 q-41 12 -101.5 -14.5t-104.5 -80t-39 -107.5q35 -53 100 -93t119 -42q51 -2 94 28t53 79zM510 53q23 -63 27 -119q195 113 392 174q-98 52 -180.5 120t-179.5 165q-6 -4 -29 -13q0 -2 -1 -5t-1 -4q31 -18 22 -37q-12 -23 -56 -34q-10 -13 -29 -24h-1q-2 -83 1 -150 q19 -34 35 -73zM579 -113q532 -21 1145 0q-254 147 -428 196q-76 -35 -156 -57q-8 -3 -16 0q-65 21 -129 49q-208 -60 -416 -188h-1v-1q1 0 1 1zM1763 -67q4 54 28 120q14 38 33 71l-1 -1q3 77 3 153q-15 8 -30 25q-42 9 -56 33q-9 20 22 38q-2 4 -2 9q-16 4 -28 12 q-204 -190 -383 -284q198 -59 414 -176zM2155 -90q5 54 -39 107.5t-104 80t-102 14.5q-38 -11 -72.5 -70.5t-51.5 -129.5q0 -16 3 -30q10 -49 53 -79t94 -28q54 2 119 42t100 93z" />
<glyph unicode="&#xf23d;" horiz-adv-x="2304" d="M1524 -25q0 -68 -48 -116t-116 -48t-116.5 48t-48.5 116t48.5 116.5t116.5 48.5t116 -48.5t48 -116.5zM775 -25q0 -68 -48.5 -116t-116.5 -48t-116 48t-48 116t48 116.5t116 48.5t116.5 -48.5t48.5 -116.5zM0 1469q57 -60 110.5 -104.5t121 -82t136 -63t166 -45.5 t200 -31.5t250 -18.5t304 -9.5t372.5 -2.5q139 0 244.5 -5t181 -16.5t124 -27.5t71 -39.5t24 -51.5t-19.5 -64t-56.5 -76.5t-89.5 -91t-116 -104.5t-139 -119q-185 -157 -286 -247q29 51 76.5 109t94 105.5t94.5 98.5t83 91.5t54 80.5t13 70t-45.5 55.5t-116.5 41t-204 23.5 t-304 5q-168 -2 -314 6t-256 23t-204.5 41t-159.5 51.5t-122.5 62.5t-91.5 66.5t-68 71.5t-50.5 69.5t-40 68t-36.5 59.5z" />
@@ -600,11 +600,11 @@
<glyph unicode="&#xf267;" horiz-adv-x="1792" d="M949 643q0 -26 -16.5 -45t-41.5 -19q-26 0 -45 16.5t-19 41.5q0 26 17 45t42 19t44 -16.5t19 -41.5zM964 585l350 581q-9 -8 -67.5 -62.5t-125.5 -116.5t-136.5 -127t-117 -110.5t-50.5 -51.5l-349 -580q7 7 67 62t126 116.5t136 127t117 111t50 50.5zM1611 640 q0 -201 -104 -371q-3 2 -17 11t-26.5 16.5t-16.5 7.5q-13 0 -13 -13q0 -10 59 -44q-74 -112 -184.5 -190.5t-241.5 -110.5l-16 67q-1 10 -15 10q-5 0 -8 -5.5t-2 -9.5l16 -68q-72 -15 -146 -15q-199 0 -372 105q1 2 13 20.5t21.5 33.5t9.5 19q0 13 -13 13q-6 0 -17 -14.5 t-22.5 -34.5t-13.5 -23q-113 75 -192 187.5t-110 244.5l69 15q10 3 10 15q0 5 -5.5 8t-10.5 2l-68 -15q-14 72 -14 139q0 206 109 379q2 -1 18.5 -12t30 -19t17.5 -8q13 0 13 12q0 6 -12.5 15.5t-32.5 21.5l-20 12q77 112 189 189t244 107l15 -67q2 -10 15 -10q5 0 8 5.5 t2 10.5l-15 66q71 13 134 13q204 0 379 -109q-39 -56 -39 -65q0 -13 12 -13q11 0 48 64q111 -75 187.5 -186t107.5 -241l-56 -12q-10 -2 -10 -16q0 -5 5.5 -8t9.5 -2l57 13q14 -72 14 -140zM1696 640q0 163 -63.5 311t-170.5 255t-255 170.5t-311 63.5t-311 -63.5 t-255 -170.5t-170.5 -255t-63.5 -311t63.5 -311t170.5 -255t255 -170.5t311 -63.5t311 63.5t255 170.5t170.5 255t63.5 311zM1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71t348 -71t286 -191 t191 -286t71 -348z" />
<glyph unicode="&#xf268;" horiz-adv-x="1792" d="M893 1536q240 2 451 -120q232 -134 352 -372l-742 39q-160 9 -294 -74.5t-185 -229.5l-276 424q128 159 311 245.5t383 87.5zM146 1131l337 -663q72 -143 211 -217t293 -45l-230 -451q-212 33 -385 157.5t-272.5 316t-99.5 411.5q0 267 146 491zM1732 962 q58 -150 59.5 -310.5t-48.5 -306t-153 -272t-246 -209.5q-230 -133 -498 -119l405 623q88 131 82.5 290.5t-106.5 277.5zM896 942q125 0 213.5 -88.5t88.5 -213.5t-88.5 -213.5t-213.5 -88.5t-213.5 88.5t-88.5 213.5t88.5 213.5t213.5 88.5z" />
<glyph unicode="&#xf269;" horiz-adv-x="1792" d="M903 -256q-283 0 -504.5 150.5t-329.5 398.5q-58 131 -67 301t26 332.5t111 312t179 242.5l-11 -281q11 14 68 15.5t70 -15.5q42 81 160.5 138t234.5 59q-54 -45 -119.5 -148.5t-58.5 -163.5q25 -8 62.5 -13.5t63 -7.5t68 -4t50.5 -3q15 -5 9.5 -45.5t-30.5 -75.5 q-5 -7 -16.5 -18.5t-56.5 -35.5t-101 -34l15 -189l-139 67q-18 -43 -7.5 -81.5t36 -66.5t65.5 -41.5t81 -6.5q51 9 98 34.5t83.5 45t73.5 17.5q61 -4 89.5 -33t19.5 -65q-1 -2 -2.5 -5.5t-8.5 -12.5t-18 -15.5t-31.5 -10.5t-46.5 -1q-60 -95 -144.5 -135.5t-209.5 -29.5 q74 -61 162.5 -82.5t168.5 -6t154.5 52t128 87.5t80.5 104q43 91 39 192.5t-37.5 188.5t-78.5 125q87 -38 137 -79.5t77 -112.5q15 170 -57.5 343t-209.5 284q265 -77 412 -279.5t151 -517.5q2 -127 -40.5 -255t-123.5 -238t-189 -196t-247.5 -135.5t-288.5 -49.5z" />
-<glyph unicode="&#xf26a;" d="M768 -92q77 0 139.5 63t100.5 166t59 234.5t21 268.5t-21 268.5t-59 234.5t-100.5 166t-139.5 63t-139.5 -63t-100.5 -166t-59 -234.5t-21 -268.5t21 -268.5t59 -234.5t100.5 -166t139.5 -63zM768 -256q-184 0 -333 77t-240 203t-141 287t-50 329t50 329t141 287t240 203 t333 77q148 0 274 -50t214.5 -136t151.5 -201t92.5 -244t29.5 -265t-29.5 -265t-92.5 -244t-151.5 -201t-214.5 -136t-274 -50z" />
-<glyph unicode="&#xf26b;" horiz-adv-x="1792" d="M716 -69q-143 35 -261.5 114t-197.5 191q-139 -300 -17 -398q26 -21 85 -24.5t127.5 9.5t141 41.5t122.5 66.5zM693 762h452q0 108 -61.5 169t-168.5 61q-103 0 -162.5 -62.5t-59.5 -167.5zM1724 1137h-34q26 102 22.5 170t-25 110t-63.5 57t-93.5 11t-115 -26.5 t-128.5 -56.5t-134 -79q129 -37 238.5 -113.5t185 -179t110 -231.5t15.5 -262h-1005q0 -60 10 -106t34 -85t69.5 -60t112.5 -21q87 0 142.5 44t72.5 122h540q-71 -230 -281.5 -377t-477.5 -147q-83 0 -159 15q-35 -40 -151 -94t-248 -78t-219 35q-78 60 -100 159t7 214 t88 242t143.5 248t173.5 226.5t177.5 183.5t156.5 112v24q-120 -37 -258.5 -137.5t-240.5 -207t-159 -195.5q4 106 34 201t80 169t118 135.5t147.5 100.5t168 65.5t180.5 29.5t185 -8q310 186 503 189h7q57 0 103 -18q80 -30 98 -132.5t-30 -248.5z" />
+<glyph unicode="&#xf26a;" horiz-adv-x="1792" d="M1493 1308q-165 110 -359 110q-155 0 -293 -73t-240 -200q-75 -93 -119.5 -218t-48.5 -266v-42q4 -141 48.5 -266t119.5 -218q102 -127 240 -200t293 -73q194 0 359 110q-121 -108 -274.5 -168t-322.5 -60q-29 0 -43 1q-175 8 -333 82t-272 193t-181 281t-67 339 q0 182 71 348t191 286t286 191t348 71h3q168 -1 320.5 -60.5t273.5 -167.5zM1792 640q0 -192 -77 -362.5t-213 -296.5q-104 -63 -222 -63q-137 0 -255 84q154 56 253.5 233t99.5 405q0 227 -99 404t-253 234q119 83 254 83q119 0 226 -65q135 -125 210.5 -295t75.5 -361z " />
+<glyph unicode="&#xf26b;" horiz-adv-x="1792" d="M1792 599q0 -56 -7 -104h-1151q0 -146 109.5 -244.5t257.5 -98.5q99 0 185.5 46.5t136.5 130.5h423q-56 -159 -170.5 -281t-267.5 -188.5t-321 -66.5q-187 0 -356 83q-228 -116 -394 -116q-237 0 -237 263q0 115 45 275q17 60 109 229q199 360 475 606 q-184 -79 -427 -354q63 274 283.5 449.5t501.5 175.5q30 0 45 -1q255 117 433 117q64 0 116 -13t94.5 -40.5t66.5 -76.5t24 -115q0 -116 -75 -286q101 -182 101 -390zM1722 1239q0 83 -53 132t-137 49q-108 0 -254 -70q121 -47 222.5 -131.5t170.5 -195.5q51 135 51 216z M128 2q0 -86 48.5 -132.5t134.5 -46.5q115 0 266 83q-122 72 -213.5 183t-137.5 245q-98 -205 -98 -332zM632 715h728q-5 142 -113 237t-251 95q-144 0 -251.5 -95t-112.5 -237z" />
<glyph unicode="&#xf26c;" horiz-adv-x="2048" d="M1792 288v960q0 13 -9.5 22.5t-22.5 9.5h-1600q-13 0 -22.5 -9.5t-9.5 -22.5v-960q0 -13 9.5 -22.5t22.5 -9.5h1600q13 0 22.5 9.5t9.5 22.5zM1920 1248v-960q0 -66 -47 -113t-113 -47h-736v-128h352q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-832q-14 0 -23 9t-9 23 v64q0 14 9 23t23 9h352v128h-736q-66 0 -113 47t-47 113v960q0 66 47 113t113 47h1600q66 0 113 -47t47 -113z" />
<glyph unicode="&#xf26d;" horiz-adv-x="1792" d="M138 1408h197q-70 -64 -126 -149q-36 -56 -59 -115t-30 -125.5t-8.5 -120t10.5 -132t21 -126t28 -136.5q4 -19 6 -28q51 -238 81 -329q57 -171 152 -275h-272q-48 0 -82 34t-34 82v1304q0 48 34 82t82 34zM1346 1408h308q48 0 82 -34t34 -82v-1304q0 -48 -34 -82t-82 -34 h-178q212 210 196 565l-469 -101q-2 -45 -12 -82t-31 -72t-59.5 -59.5t-93.5 -36.5q-123 -26 -199 40q-32 27 -53 61t-51.5 129t-64.5 258q-35 163 -45.5 263t-5.5 139t23 77q20 41 62.5 73t102.5 45q45 12 83.5 6.5t67 -17t54 -35t43 -48t34.5 -56.5l468 100 q-68 175 -180 287z" />
-<glyph unicode="&#xf26e;" horiz-adv-x="2304" d="M1391 390v0l-1 1q-15 18 -34.5 37.5t-62.5 57.5t-93.5 62t-95.5 24q-48 0 -83 -21.5t-51 -54t-23 -59t-7 -47.5v0v0q0 -21 7 -48t23 -59t51 -53.5t83 -21.5q45 0 95.5 24t94 62.5t62 57t34.5 37.5zM2103 390q0 21 -7 47.5t-23 59t-51 54t-83 21.5q-45 0 -95.5 -24 t-94 -62.5t-62 -57t-34.5 -37.5l-1 -1v0v0l1 -1q15 -18 34.5 -37.5t62.5 -57.5t93.5 -62t95.5 -24q48 0 83 21.5t51 53.5t23 59t7 48zM2304 393q0 -69 -24 -137.5t-68 -126t-116 -93.5t-159 -36q-68 0 -134 24t-113.5 58.5t-84.5 69.5t-59.5 59t-25.5 24t-22.5 -24 t-54.5 -58.5t-81.5 -69.5t-115 -59t-143.5 -24q-65 0 -123.5 22.5t-96.5 54t-66.5 66.5t-41 59.5t-12.5 32.5q0 -8 -8.5 -26.5t-25 -45.5t-47 -55t-69 -52.5t-96.5 -40t-125 -15.5q-71 0 -130 15.5t-98.5 39.5t-70.5 56.5t-48 63.5t-27.5 63.5t-14 54t-3.5 36.5h217 q0 -55 49 -107.5t126 -52.5q79 0 134.5 67t55.5 148q0 80 -52 136.5t-138 56.5q-5 0 -13 -0.5t-31 -5t-43 -12t-42 -24.5t-34 -40h-195l102 583h602v-174h-445q-27 -159 -41 -248q4 0 16.5 13t31.5 28.5t65 28.5t108 13t114 -20.5t82.5 -49.5t51.5 -58.5t31 -50t11 -20.5 t13 25t36.5 60.5t60.5 71.5t97 61t133 25t140.5 -25t115.5 -60.5t83.5 -71.5t56.5 -61t21 -25q2 0 22 25t56 60.5t83.5 71.5t115.5 61t140 25q92 0 164.5 -35t115.5 -93t65 -125t22 -137z" />
+<glyph unicode="&#xf26e;" d="M1401 -11l-6 -6q-113 -114 -259 -175q-154 -64 -317 -64q-165 0 -317 64q-148 63 -259 175q-113 112 -175 258q-42 103 -54 189q-4 28 48 36q51 8 56 -20q1 -1 1 -4q18 -90 46 -159q50 -124 152 -226q98 -98 226 -152q132 -56 276 -56q143 0 276 56q128 55 225 152l6 6 q10 10 25 6q12 -3 33 -22q36 -37 17 -58zM929 604l-66 -66l63 -63q21 -21 -7 -49q-17 -17 -32 -17q-10 0 -19 10l-62 61l-66 -66q-5 -5 -15 -5q-15 0 -31 16l-2 2q-18 15 -18 29q0 7 8 17l66 65l-66 66q-16 16 14 45q18 18 31 18q6 0 13 -5l65 -66l65 65q18 17 48 -13 q27 -27 11 -44zM1400 547q0 -118 -46 -228q-45 -105 -126 -186q-80 -80 -187 -126t-228 -46t-228 46t-187 126q-82 82 -125 186q-15 32 -15 40h-1q-9 27 43 44q50 16 60 -12q37 -99 97 -167h1v339v2q3 136 102 232q105 103 253 103q147 0 251 -103t104 -249 q0 -147 -104.5 -251t-250.5 -104q-58 0 -112 16q-28 11 -13 61q16 51 44 43l14 -3q14 -3 32.5 -6t30.5 -3q104 0 176 71.5t72 174.5q0 101 -72 171q-71 71 -175 71q-107 0 -178 -80q-64 -72 -64 -160v-413q110 -67 242 -67q96 0 185 36.5t156 103.5t103.5 155t36.5 183 q0 198 -141 339q-140 140 -339 140q-200 0 -340 -140q-53 -53 -77 -87l-2 -2q-8 -11 -13 -15.5t-21.5 -9.5t-38.5 3q-21 5 -36.5 16.5t-15.5 26.5v680q0 15 10.5 26.5t27.5 11.5h877q30 0 30 -55t-30 -55h-811v-483h1q40 42 102 84t108 61q109 46 231 46q121 0 228 -46 t187 -126q81 -81 126 -186q46 -112 46 -229zM1369 1128q9 -8 9 -18t-5.5 -18t-16.5 -21q-26 -26 -39 -26q-9 0 -16 7q-106 91 -207 133q-128 56 -276 56q-133 0 -262 -49q-27 -10 -45 37q-9 25 -8 38q3 16 16 20q130 57 299 57q164 0 316 -64q137 -58 235 -152z" />
<glyph unicode="&#xf270;" horiz-adv-x="1792" d="M1551 60q15 6 26 3t11 -17.5t-15 -33.5q-13 -16 -44 -43.5t-95.5 -68t-141 -74t-188 -58t-229.5 -24.5q-119 0 -238 31t-209 76.5t-172.5 104t-132.5 105t-84 87.5q-8 9 -10 16.5t1 12t8 7t11.5 2t11.5 -4.5q192 -117 300 -166q389 -176 799 -90q190 40 391 135z M1758 175q11 -16 2.5 -69.5t-28.5 -102.5q-34 -83 -85 -124q-17 -14 -26 -9t0 24q21 45 44.5 121.5t6.5 98.5q-5 7 -15.5 11.5t-27 6t-29.5 2.5t-35 0t-31.5 -2t-31 -3t-22.5 -2q-6 -1 -13 -1.5t-11 -1t-8.5 -1t-7 -0.5h-5.5h-4.5t-3 0.5t-2 1.5l-1.5 3q-6 16 47 40t103 30 q46 7 108 1t76 -24zM1364 618q0 -31 13.5 -64t32 -58t37.5 -46t33 -32l13 -11l-227 -224q-40 37 -79 75.5t-58 58.5l-19 20q-11 11 -25 33q-38 -59 -97.5 -102.5t-127.5 -63.5t-140 -23t-137.5 21t-117.5 65.5t-83 113t-31 162.5q0 84 28 154t72 116.5t106.5 83t122.5 57 t130 34.5t119.5 18.5t99.5 6.5v127q0 65 -21 97q-34 53 -121 53q-6 0 -16.5 -1t-40.5 -12t-56 -29.5t-56 -59.5t-48 -96l-294 27q0 60 22 119t67 113t108 95t151.5 65.5t190.5 24.5q100 0 181 -25t129.5 -61.5t81 -83t45 -86t12.5 -73.5v-589zM692 597q0 -86 70 -133 q66 -44 139 -22q84 25 114 123q14 45 14 101v162q-59 -2 -111 -12t-106.5 -33.5t-87 -71t-32.5 -114.5z" />
<glyph unicode="&#xf271;" horiz-adv-x="1792" d="M1536 1280q52 0 90 -38t38 -90v-1280q0 -52 -38 -90t-90 -38h-1408q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h128v96q0 66 47 113t113 47h64q66 0 113 -47t47 -113v-96h384v96q0 66 47 113t113 47h64q66 0 113 -47t47 -113v-96h128zM1152 1376v-288q0 -14 9 -23t23 -9 h64q14 0 23 9t9 23v288q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23zM384 1376v-288q0 -14 9 -23t23 -9h64q14 0 23 9t9 23v288q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23zM1536 -128v1024h-1408v-1024h1408zM896 448h224q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-224 v-224q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v224h-224q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h224v224q0 14 9 23t23 9h64q14 0 23 -9t9 -23v-224z" />
<glyph unicode="&#xf272;" horiz-adv-x="1792" d="M1152 416v-64q0 -14 -9 -23t-23 -9h-576q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h576q14 0 23 -9t9 -23zM128 -128h1408v1024h-1408v-1024zM512 1088v288q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-288q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1280 1088v288q0 14 -9 23 t-23 9h-64q-14 0 -23 -9t-9 -23v-288q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1664 1152v-1280q0 -52 -38 -90t-90 -38h-1408q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h128v96q0 66 47 113t113 47h64q66 0 113 -47t47 -113v-96h384v96q0 66 47 113t113 47h64q66 0 113 -47 t47 -113v-96h128q52 0 90 -38t38 -90z" />
@@ -621,20 +621,35 @@
<glyph unicode="&#xf27d;" horiz-adv-x="1792" d="M1709 1018q-10 -236 -332 -651q-333 -431 -562 -431q-142 0 -240 263q-44 160 -132 482q-72 262 -157 262q-18 0 -127 -76l-77 98q24 21 108 96.5t130 115.5q156 138 241 146q95 9 153 -55.5t81 -203.5q44 -287 66 -373q55 -249 120 -249q51 0 154 161q101 161 109 246 q13 139 -109 139q-57 0 -121 -26q120 393 459 382q251 -8 236 -326z" />
<glyph unicode="&#xf27e;" d="M0 1408h1536v-1536h-1536v1536zM1085 293l-221 631l221 297h-634l221 -297l-221 -631l317 -304z" />
<glyph unicode="&#xf280;" d="M0 1408h1536v-1536h-1536v1536zM908 1088l-12 -33l75 -83l-31 -114l25 -25l107 57l107 -57l25 25l-31 114l75 83l-12 33h-95l-53 96h-32l-53 -96h-95zM641 925q32 0 44.5 -16t11.5 -63l174 21q0 55 -17.5 92.5t-50.5 56t-69 25.5t-85 7q-133 0 -199 -57.5t-66 -182.5v-72 h-96v-128h76q20 0 20 -8v-382q0 -14 -5 -20t-18 -7l-73 -7v-88h448v86l-149 14q-6 1 -8.5 1.5t-3.5 2.5t-0.5 4t1 7t0.5 10v387h191l38 128h-231q-6 0 -2 6t4 9v80q0 27 1.5 40.5t7.5 28t19.5 20t36.5 5.5zM1248 96v86l-54 9q-7 1 -9.5 2.5t-2.5 3t1 7.5t1 12v520h-275 l-23 -101l83 -22q23 -7 23 -27v-370q0 -14 -6 -18.5t-20 -6.5l-70 -9v-86h352z" />
-<glyph unicode="&#xf281;" horiz-adv-x="1792" />
-<glyph unicode="&#xf282;" horiz-adv-x="1792" />
-<glyph unicode="&#xf283;" horiz-adv-x="1792" />
-<glyph unicode="&#xf284;" horiz-adv-x="1792" />
-<glyph unicode="&#xf285;" horiz-adv-x="1792" />
-<glyph unicode="&#xf286;" horiz-adv-x="1792" />
-<glyph unicode="&#xf287;" horiz-adv-x="1792" />
-<glyph unicode="&#xf288;" horiz-adv-x="1792" />
-<glyph unicode="&#xf289;" horiz-adv-x="1792" />
-<glyph unicode="&#xf28a;" horiz-adv-x="1792" />
-<glyph unicode="&#xf28b;" horiz-adv-x="1792" />
-<glyph unicode="&#xf28c;" horiz-adv-x="1792" />
-<glyph unicode="&#xf28d;" horiz-adv-x="1792" />
-<glyph unicode="&#xf28e;" horiz-adv-x="1792" />
+<glyph unicode="&#xf281;" horiz-adv-x="1792" d="M1792 690q0 -58 -29.5 -105.5t-79.5 -72.5q12 -46 12 -96q0 -155 -106.5 -287t-290.5 -208.5t-400 -76.5t-399.5 76.5t-290 208.5t-106.5 287q0 47 11 94q-51 25 -82 73.5t-31 106.5q0 82 58 140.5t141 58.5q85 0 145 -63q218 152 515 162l116 521q3 13 15 21t26 5 l369 -81q18 37 54 59.5t79 22.5q62 0 106 -43.5t44 -105.5t-44 -106t-106 -44t-105.5 43.5t-43.5 105.5l-334 74l-104 -472q300 -9 519 -160q58 61 143 61q83 0 141 -58.5t58 -140.5zM418 491q0 -62 43.5 -106t105.5 -44t106 44t44 106t-44 105.5t-106 43.5q-61 0 -105 -44 t-44 -105zM1228 136q11 11 11 26t-11 26q-10 10 -25 10t-26 -10q-41 -42 -121 -62t-160 -20t-160 20t-121 62q-11 10 -26 10t-25 -10q-11 -10 -11 -25.5t11 -26.5q43 -43 118.5 -68t122.5 -29.5t91 -4.5t91 4.5t122.5 29.5t118.5 68zM1225 341q62 0 105.5 44t43.5 106 q0 61 -44 105t-105 44q-62 0 -106 -43.5t-44 -105.5t44 -106t106 -44z" />
+<glyph unicode="&#xf282;" horiz-adv-x="1792" d="M69 741h1q16 126 58.5 241.5t115 217t167.5 176t223.5 117.5t276.5 43q231 0 414 -105.5t294 -303.5q104 -187 104 -442v-188h-1125q1 -111 53.5 -192.5t136.5 -122.5t189.5 -57t213 -3t208 46.5t173.5 84.5v-377q-92 -55 -229.5 -92t-312.5 -38t-316 53 q-189 73 -311.5 249t-124.5 372q-3 242 111 412t325 268q-48 -60 -78 -125.5t-46 -159.5h635q8 77 -8 140t-47 101.5t-70.5 66.5t-80.5 41t-75 20.5t-56 8.5l-22 1q-135 -5 -259.5 -44.5t-223.5 -104.5t-176 -140.5t-138 -163.5z" />
+<glyph unicode="&#xf283;" horiz-adv-x="2304" d="M0 32v608h2304v-608q0 -66 -47 -113t-113 -47h-1984q-66 0 -113 47t-47 113zM640 256v-128h384v128h-384zM256 256v-128h256v128h-256zM2144 1408q66 0 113 -47t47 -113v-224h-2304v224q0 66 47 113t113 47h1984z" />
+<glyph unicode="&#xf284;" horiz-adv-x="1792" d="M1549 857q55 0 85.5 -28.5t30.5 -83.5t-34 -82t-91 -27h-136v-177h-25v398h170zM1710 267l-4 -11l-5 -10q-113 -230 -330.5 -366t-474.5 -136q-182 0 -348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71q244 0 454.5 -124t329.5 -338l2 -4l8 -16 q-30 -15 -136.5 -68.5t-163.5 -84.5q-6 -3 -479 -268q384 -183 799 -366zM896 -234q250 0 462.5 132.5t322.5 357.5l-287 129q-72 -140 -206 -222t-292 -82q-151 0 -280 75t-204 204t-75 280t75 280t204 204t280 75t280 -73.5t204 -204.5l280 143q-116 208 -321 329 t-443 121q-119 0 -232.5 -31.5t-209 -87.5t-176.5 -137t-137 -176.5t-87.5 -209t-31.5 -232.5t31.5 -232.5t87.5 -209t137 -176.5t176.5 -137t209 -87.5t232.5 -31.5z" />
+<glyph unicode="&#xf285;" horiz-adv-x="1792" d="M1427 827l-614 386l92 151h855zM405 562l-184 116v858l1183 -743zM1424 697l147 -95v-858l-532 335zM1387 718l-500 -802h-855l356 571z" />
+<glyph unicode="&#xf286;" horiz-adv-x="1792" d="M640 528v224q0 16 -16 16h-96q-16 0 -16 -16v-224q0 -16 16 -16h96q16 0 16 16zM1152 528v224q0 16 -16 16h-96q-16 0 -16 -16v-224q0 -16 16 -16h96q16 0 16 16zM1664 496v-752h-640v320q0 80 -56 136t-136 56t-136 -56t-56 -136v-320h-640v752q0 16 16 16h96 q16 0 16 -16v-112h128v624q0 16 16 16h96q16 0 16 -16v-112h128v112q0 16 16 16h96q16 0 16 -16v-112h128v112q0 16 16 16h16v393q-32 19 -32 55q0 26 19 45t45 19t45 -19t19 -45q0 -36 -32 -55v-9h272q16 0 16 -16v-224q0 -16 -16 -16h-272v-128h16q16 0 16 -16v-112h128 v112q0 16 16 16h96q16 0 16 -16v-112h128v112q0 16 16 16h96q16 0 16 -16v-624h128v112q0 16 16 16h96q16 0 16 -16z" />
+<glyph unicode="&#xf287;" horiz-adv-x="2304" d="M2288 731q16 -8 16 -27t-16 -27l-320 -192q-8 -5 -16 -5q-9 0 -16 4q-16 10 -16 28v128h-858q37 -58 83 -165q16 -37 24.5 -55t24 -49t27 -47t27 -34t31.5 -26t33 -8h96v96q0 14 9 23t23 9h320q14 0 23 -9t9 -23v-320q0 -14 -9 -23t-23 -9h-320q-14 0 -23 9t-9 23v96h-96 q-32 0 -61 10t-51 23.5t-45 40.5t-37 46t-33.5 57t-28.5 57.5t-28 60.5q-23 53 -37 81.5t-36 65t-44.5 53.5t-46.5 17h-360q-22 -84 -91 -138t-157 -54q-106 0 -181 75t-75 181t75 181t181 75q88 0 157 -54t91 -138h104q24 0 46.5 17t44.5 53.5t36 65t37 81.5q19 41 28 60.5 t28.5 57.5t33.5 57t37 46t45 40.5t51 23.5t61 10h107q21 57 70 92.5t111 35.5q80 0 136 -56t56 -136t-56 -136t-136 -56q-62 0 -111 35.5t-70 92.5h-107q-17 0 -33 -8t-31.5 -26t-27 -34t-27 -47t-24 -49t-24.5 -55q-46 -107 -83 -165h1114v128q0 18 16 28t32 -1z" />
+<glyph unicode="&#xf288;" horiz-adv-x="1792" d="M1150 774q0 -56 -39.5 -95t-95.5 -39h-253v269h253q56 0 95.5 -39.5t39.5 -95.5zM1329 774q0 130 -91.5 222t-222.5 92h-433v-896h180v269h253q130 0 222 91.5t92 221.5zM1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348 t71 348t191 286t286 191t348 71t348 -71t286 -191t191 -286t71 -348z" />
+<glyph unicode="&#xf289;" horiz-adv-x="2304" d="M1645 438q0 59 -34 106.5t-87 68.5q-7 -45 -23 -92q-7 -24 -27.5 -38t-44.5 -14q-12 0 -24 3q-31 10 -45 38.5t-4 58.5q23 71 23 143q0 123 -61 227.5t-166 165.5t-228 61q-134 0 -247 -73t-167 -194q108 -28 188 -106q22 -23 22 -55t-22 -54t-54 -22t-55 22 q-75 75 -180 75q-106 0 -181 -74.5t-75 -180.5t75 -180.5t181 -74.5h1046q79 0 134.5 55.5t55.5 133.5zM1798 438q0 -142 -100.5 -242t-242.5 -100h-1046q-169 0 -289 119.5t-120 288.5q0 153 100 267t249 136q62 184 221 298t354 114q235 0 408.5 -158.5t196.5 -389.5 q116 -25 192.5 -118.5t76.5 -214.5zM2048 438q0 -175 -97 -319q-23 -33 -64 -33q-24 0 -43 13q-26 17 -32 48.5t12 57.5q71 104 71 233t-71 233q-18 26 -12 57t32 49t57.5 11.5t49.5 -32.5q97 -142 97 -318zM2304 438q0 -244 -134 -443q-23 -34 -64 -34q-23 0 -42 13 q-26 18 -32.5 49t11.5 57q108 164 108 358q0 195 -108 357q-18 26 -11.5 57.5t32.5 48.5q26 18 57 12t49 -33q134 -198 134 -442z" />
+<glyph unicode="&#xf28a;" d="M1500 -13q0 -89 -63 -152.5t-153 -63.5t-153.5 63.5t-63.5 152.5q0 90 63.5 153.5t153.5 63.5t153 -63.5t63 -153.5zM1267 268q-115 -15 -192.5 -102.5t-77.5 -205.5q0 -74 33 -138q-146 -78 -379 -78q-109 0 -201 21t-153.5 54.5t-110.5 76.5t-76 85t-44.5 83 t-23.5 66.5t-6 39.5q0 19 4.5 42.5t18.5 56t36.5 58t64 43.5t94.5 18t94 -17.5t63 -41t35.5 -53t17.5 -49t4 -33.5q0 -34 -23 -81q28 -27 82 -42t93 -17l40 -1q115 0 190 51t75 133q0 26 -9 48.5t-31.5 44.5t-49.5 41t-74 44t-93.5 47.5t-119.5 56.5q-28 13 -43 20 q-116 55 -187 100t-122.5 102t-72 125.5t-20.5 162.5q0 78 20.5 150t66 137.5t112.5 114t166.5 77t221.5 28.5q120 0 220 -26t164.5 -67t109.5 -94t64 -105.5t19 -103.5q0 -46 -15 -82.5t-36.5 -58t-48.5 -36t-49 -19.5t-39 -5h-8h-32t-39 5t-44 14t-41 28t-37 46t-24 70.5 t-10 97.5q-15 16 -59 25.5t-81 10.5l-37 1q-68 0 -117.5 -31t-70.5 -70t-21 -76q0 -24 5 -43t24 -46t53 -51t97 -53.5t150 -58.5q76 -25 138.5 -53.5t109 -55.5t83 -59t60.5 -59.5t41 -62.5t26.5 -62t14.5 -63.5t6 -62t1 -62.5z" />
+<glyph unicode="&#xf28b;" d="M704 352v576q0 14 -9 23t-23 9h-256q-14 0 -23 -9t-9 -23v-576q0 -14 9 -23t23 -9h256q14 0 23 9t9 23zM1152 352v576q0 14 -9 23t-23 9h-256q-14 0 -23 -9t-9 -23v-576q0 -14 9 -23t23 -9h256q14 0 23 9t9 23zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103 t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" />
+<glyph unicode="&#xf28c;" d="M768 1408q209 0 385.5 -103t279.5 -279.5t103 -385.5t-103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103zM768 96q148 0 273 73t198 198t73 273t-73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273 t73 -273t198 -198t273 -73zM864 320q-14 0 -23 9t-9 23v576q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-576q0 -14 -9 -23t-23 -9h-192zM480 320q-14 0 -23 9t-9 23v576q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-576q0 -14 -9 -23t-23 -9h-192z" />
+<glyph unicode="&#xf28d;" d="M1088 352v576q0 14 -9 23t-23 9h-576q-14 0 -23 -9t-9 -23v-576q0 -14 9 -23t23 -9h576q14 0 23 9t9 23zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5 t103 -385.5z" />
+<glyph unicode="&#xf28e;" d="M768 1408q209 0 385.5 -103t279.5 -279.5t103 -385.5t-103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103zM768 96q148 0 273 73t198 198t73 273t-73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273 t73 -273t198 -198t273 -73zM480 320q-14 0 -23 9t-9 23v576q0 14 9 23t23 9h576q14 0 23 -9t9 -23v-576q0 -14 -9 -23t-23 -9h-576z" />
+<glyph unicode="&#xf290;" horiz-adv-x="1792" d="M1757 128l35 -313q3 -28 -16 -50q-19 -21 -48 -21h-1664q-29 0 -48 21q-19 22 -16 50l35 313h1722zM1664 967l86 -775h-1708l86 775q3 24 21 40.5t43 16.5h256v-128q0 -53 37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5v128h384v-128q0 -53 37.5 -90.5t90.5 -37.5 t90.5 37.5t37.5 90.5v128h256q25 0 43 -16.5t21 -40.5zM1280 1152v-256q0 -26 -19 -45t-45 -19t-45 19t-19 45v256q0 106 -75 181t-181 75t-181 -75t-75 -181v-256q0 -26 -19 -45t-45 -19t-45 19t-19 45v256q0 159 112.5 271.5t271.5 112.5t271.5 -112.5t112.5 -271.5z" />
+<glyph unicode="&#xf291;" horiz-adv-x="2048" d="M1920 768q53 0 90.5 -37.5t37.5 -90.5t-37.5 -90.5t-90.5 -37.5h-15l-115 -662q-8 -46 -44 -76t-82 -30h-1280q-46 0 -82 30t-44 76l-115 662h-15q-53 0 -90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5h1792zM485 -32q26 2 43.5 22.5t15.5 46.5l-32 416q-2 26 -22.5 43.5 t-46.5 15.5t-43.5 -22.5t-15.5 -46.5l32 -416q2 -25 20.5 -42t43.5 -17h5zM896 32v416q0 26 -19 45t-45 19t-45 -19t-19 -45v-416q0 -26 19 -45t45 -19t45 19t19 45zM1280 32v416q0 26 -19 45t-45 19t-45 -19t-19 -45v-416q0 -26 19 -45t45 -19t45 19t19 45zM1632 27l32 416 q2 26 -15.5 46.5t-43.5 22.5t-46.5 -15.5t-22.5 -43.5l-32 -416q-2 -26 15.5 -46.5t43.5 -22.5h5q25 0 43.5 17t20.5 42zM476 1244l-93 -412h-132l101 441q19 88 89 143.5t160 55.5h167q0 26 19 45t45 19h384q26 0 45 -19t19 -45h167q90 0 160 -55.5t89 -143.5l101 -441 h-132l-93 412q-11 44 -45.5 72t-79.5 28h-167q0 -26 -19 -45t-45 -19h-384q-26 0 -45 19t-19 45h-167q-45 0 -79.5 -28t-45.5 -72z" />
+<glyph unicode="&#xf292;" horiz-adv-x="1792" d="M991 512l64 256h-254l-64 -256h254zM1759 1016l-56 -224q-7 -24 -31 -24h-327l-64 -256h311q15 0 25 -12q10 -14 6 -28l-56 -224q-5 -24 -31 -24h-327l-81 -328q-7 -24 -31 -24h-224q-16 0 -26 12q-9 12 -6 28l78 312h-254l-81 -328q-7 -24 -31 -24h-225q-15 0 -25 12 q-9 12 -6 28l78 312h-311q-15 0 -25 12q-9 12 -6 28l56 224q7 24 31 24h327l64 256h-311q-15 0 -25 12q-10 14 -6 28l56 224q5 24 31 24h327l81 328q7 24 32 24h224q15 0 25 -12q9 -12 6 -28l-78 -312h254l81 328q7 24 32 24h224q15 0 25 -12q9 -12 6 -28l-78 -312h311 q15 0 25 -12q9 -12 6 -28z" />
+<glyph unicode="&#xf293;" d="M841 483l148 -148l-149 -149zM840 1094l149 -149l-148 -148zM710 -130l464 464l-306 306l306 306l-464 464v-611l-255 255l-93 -93l320 -321l-320 -321l93 -93l255 255v-611zM1429 640q0 -209 -32 -365.5t-87.5 -257t-140.5 -162.5t-181.5 -86.5t-219.5 -24.5 t-219.5 24.5t-181.5 86.5t-140.5 162.5t-87.5 257t-32 365.5t32 365.5t87.5 257t140.5 162.5t181.5 86.5t219.5 24.5t219.5 -24.5t181.5 -86.5t140.5 -162.5t87.5 -257t32 -365.5z" />
+<glyph unicode="&#xf294;" horiz-adv-x="1024" d="M596 113l173 172l-173 172v-344zM596 823l173 172l-173 172v-344zM628 640l356 -356l-539 -540v711l-297 -296l-108 108l372 373l-372 373l108 108l297 -296v711l539 -540z" />
+<glyph unicode="&#xf295;" d="M1280 256q0 52 -38 90t-90 38t-90 -38t-38 -90t38 -90t90 -38t90 38t38 90zM512 1024q0 52 -38 90t-90 38t-90 -38t-38 -90t38 -90t90 -38t90 38t38 90zM1536 256q0 -159 -112.5 -271.5t-271.5 -112.5t-271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5t271.5 -112.5 t112.5 -271.5zM1440 1344q0 -20 -13 -38l-1056 -1408q-19 -26 -51 -26h-160q-26 0 -45 19t-19 45q0 20 13 38l1056 1408q19 26 51 26h160q26 0 45 -19t19 -45zM768 1024q0 -159 -112.5 -271.5t-271.5 -112.5t-271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5 t271.5 -112.5t112.5 -271.5z" />
+<glyph unicode="&#xf296;" horiz-adv-x="1792" />
+<glyph unicode="&#xf297;" horiz-adv-x="1792" />
+<glyph unicode="&#xf298;" horiz-adv-x="1792" />
+<glyph unicode="&#xf299;" horiz-adv-x="1792" />
+<glyph unicode="&#xf29a;" horiz-adv-x="1792" />
+<glyph unicode="&#xf29b;" horiz-adv-x="1792" />
+<glyph unicode="&#xf29c;" horiz-adv-x="1792" />
+<glyph unicode="&#xf29d;" horiz-adv-x="1792" />
+<glyph unicode="&#xf29e;" horiz-adv-x="1792" />
<glyph unicode="&#xf500;" horiz-adv-x="1792" />
</font>
</defs></svg> \ No newline at end of file
diff --git a/assets/fonts/fontawesome-webfont.ttf b/assets/fonts/fontawesome-webfont.ttf
index d7994e13..26dea795 100644
--- a/assets/fonts/fontawesome-webfont.ttf
+++ b/assets/fonts/fontawesome-webfont.ttf
Binary files differ
diff --git a/assets/fonts/fontawesome-webfont.woff b/assets/fonts/fontawesome-webfont.woff
index 6fd4ede0..dc35ce3c 100644
--- a/assets/fonts/fontawesome-webfont.woff
+++ b/assets/fonts/fontawesome-webfont.woff
Binary files differ
diff --git a/assets/fonts/fontawesome-webfont.woff2 b/assets/fonts/fontawesome-webfont.woff2
index 5560193c..500e5172 100644
--- a/assets/fonts/fontawesome-webfont.woff2
+++ b/assets/fonts/fontawesome-webfont.woff2
Binary files differ
diff --git a/assets/img/gitlab-icon.png b/assets/img/gitlab-icon.png
deleted file mode 100644
index 88d48b40..00000000
--- a/assets/img/gitlab-icon.png
+++ /dev/null
Binary files differ
diff --git a/assets/js/app.js b/assets/js/app.js
index d5e9085d..1ce73db0 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -51,4 +51,1231 @@ c,a,e),l[d.key][c?"unshift":"push"]({callback:b,modifiers:d.modifiers,action:d.a
unbind:function(a,b){return m.bind(a,function(){},b)},trigger:function(a,b){if(q[a+":"+b])q[a+":"+b]({},a);return this},reset:function(){l={};q={};return this},stopCallback:function(a,b){return-1<(" "+b.className+" ").indexOf(" mousetrap ")?!1:"INPUT"==b.tagName||"SELECT"==b.tagName||"TEXTAREA"==b.tagName||b.isContentEditable},handleKey:function(a,b,d){var c=C(a,b,d),e;b={};var f=0,g=!1;for(e=0;e<c.length;++e)c[e].seq&&(f=Math.max(f,c[e].level));for(e=0;e<c.length;++e)c[e].seq?c[e].level==f&&(g=!0,
b[c[e].seq]=1,x(c[e].callback,d,c[e].combo,c[e].seq)):g||x(c[e].callback,d,c[e].combo);c="keypress"==d.type&&I;d.type!=u||w(a)||c||t(b);I=g&&"keydown"==d.type}};J.Mousetrap=m;"function"===typeof define&&define.amd&&define(m)})(window,document);
Mousetrap=function(a){var d={},e=a.stopCallback;a.stopCallback=function(b,c,a){return d[a]?!1:e(b,c,a)};a.bindGlobal=function(b,c,e){a.bind(b,c,e);if(b instanceof Array)for(c=0;c<b.length;c++)d[b[c]]=!0;else d[b]=!0};return a}(Mousetrap);
-!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"da",{months:"januar_februar_marts_april_maj_juni_juli_august_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"søndag_mandag_tirsdag_onsdag_torsdag_fredag_lørdag".split("_"),weekdaysShort:"søn_man_tir_ons_tor_fre_lør".split("_"),weekdaysMin:"sø_ma_ti_on_to_fr_lø".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd [d.] D. MMMM YYYY LT"},calendar:{sameDay:"[I dag kl.] LT",nextDay:"[I morgen kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[I går kl.] LT",lastWeek:"[sidste] dddd [kl] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"%s siden",s:"få sekunder",m:"et minut",mm:"%d minutter",h:"en time",hh:"%d timer",d:"en dag",dd:"%d dage",M:"en måned",MM:"%d måneder",y:"et år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("da","da",{closeText:"Luk",prevText:"&#x3C;Forrige",nextText:"Næste&#x3E;",currentText:"Idag",monthNames:["Januar","Februar","Marts","April","Maj","Juni","Juli","August","September","Oktober","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],dayNames:["Søndag","Mandag","Tirsdag","Onsdag","Torsdag","Fredag","Lørdag"],dayNamesShort:["Søn","Man","Tir","Ons","Tor","Fre","Lør"],dayNamesMin:["Sø","Ma","Ti","On","To","Fr","Lø"],weekHeader:"Uge",dateFormat:"dd-mm-yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("da",{buttonText:{month:"Måned",week:"Uge",day:"Dag",list:"Agenda"},allDayText:"Hele dagen",eventLimitText:"flere"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b,c,d){var e={m:["eine Minute","einer Minute"],h:["eine Stunde","einer Stunde"],d:["ein Tag","einem Tag"],dd:[a+" Tage",a+" Tagen"],M:["ein Monat","einem Monat"],MM:[a+" Monate",a+" Monaten"],y:["ein Jahr","einem Jahr"],yy:[a+" Jahre",a+" Jahren"]};return b?e[c][0]:e[c][1]}(b.defineLocale||b.lang).call(b,"de",{months:"Januar_Februar_März_April_Mai_Juni_Juli_August_September_Oktober_November_Dezember".split("_"),monthsShort:"Jan._Febr._Mrz._Apr._Mai_Jun._Jul._Aug._Sept._Okt._Nov._Dez.".split("_"),weekdays:"Sonntag_Montag_Dienstag_Mittwoch_Donnerstag_Freitag_Samstag".split("_"),weekdaysShort:"So._Mo._Di._Mi._Do._Fr._Sa.".split("_"),weekdaysMin:"So_Mo_Di_Mi_Do_Fr_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[Heute um] LT [Uhr]",sameElse:"L",nextDay:"[Morgen um] LT [Uhr]",nextWeek:"dddd [um] LT [Uhr]",lastDay:"[Gestern um] LT [Uhr]",lastWeek:"[letzten] dddd [um] LT [Uhr]"},relativeTime:{future:"in %s",past:"vor %s",s:"ein paar Sekunden",m:c,mm:"%d Minuten",h:c,hh:"%d Stunden",d:c,dd:c,M:c,MM:c,y:c,yy:c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("de","de",{closeText:"Schließen",prevText:"&#x3C;Zurück",nextText:"Vor&#x3E;",currentText:"Heute",monthNames:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],monthNamesShort:["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],dayNames:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"],dayNamesShort:["So","Mo","Di","Mi","Do","Fr","Sa"],dayNamesMin:["So","Mo","Di","Mi","Do","Fr","Sa"],weekHeader:"KW",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("de",{buttonText:{month:"Monat",week:"Woche",day:"Tag",list:"Terminübersicht"},allDayText:"Ganztägig",eventLimitText:function(a){return"+ weitere "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c="ene._feb._mar._abr._may._jun._jul._ago._sep._oct._nov._dic.".split("_"),d="ene_feb_mar_abr_may_jun_jul_ago_sep_oct_nov_dic".split("_");(b.defineLocale||b.lang).call(b,"es",{months:"enero_febrero_marzo_abril_mayo_junio_julio_agosto_septiembre_octubre_noviembre_diciembre".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?d[a.month()]:c[a.month()]},weekdays:"domingo_lunes_martes_miércoles_jueves_viernes_sábado".split("_"),weekdaysShort:"dom._lun._mar._mié._jue._vie._sáb.".split("_"),weekdaysMin:"Do_Lu_Ma_Mi_Ju_Vi_Sá".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY LT",LLLL:"dddd, D [de] MMMM [de] YYYY LT"},calendar:{sameDay:function(){return"[hoy a la"+(1!==this.hours()?"s":"")+"] LT"},nextDay:function(){return"[mañana a la"+(1!==this.hours()?"s":"")+"] LT"},nextWeek:function(){return"dddd [a la"+(1!==this.hours()?"s":"")+"] LT"},lastDay:function(){return"[ayer a la"+(1!==this.hours()?"s":"")+"] LT"},lastWeek:function(){return"[el] dddd [pasado a la"+(1!==this.hours()?"s":"")+"] LT"},sameElse:"L"},relativeTime:{future:"en %s",past:"hace %s",s:"unos segundos",m:"un minuto",mm:"%d minutos",h:"una hora",hh:"%d horas",d:"un día",dd:"%d días",M:"un mes",MM:"%d meses",y:"un año",yy:"%d años"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("es","es",{closeText:"Cerrar",prevText:"&#x3C;Ant",nextText:"Sig&#x3E;",currentText:"Hoy",monthNames:["enero","febrero","marzo","abril","mayo","junio","julio","agosto","septiembre","octubre","noviembre","diciembre"],monthNamesShort:["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"],dayNames:["domingo","lunes","martes","miércoles","jueves","viernes","sábado"],dayNamesShort:["dom","lun","mar","mié","jue","vie","sáb"],dayNamesMin:["D","L","M","X","J","V","S"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("es",{buttonText:{month:"Mes",week:"Semana",day:"Día",list:"Agenda"},allDayHtml:"Todo<br/>el día",eventLimitText:"más"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b,c,e){var f="";switch(c){case"s":return e?"muutaman sekunnin":"muutama sekunti";case"m":return e?"minuutin":"minuutti";case"mm":f=e?"minuutin":"minuuttia";break;case"h":return e?"tunnin":"tunti";case"hh":f=e?"tunnin":"tuntia";break;case"d":return e?"päivän":"päivä";case"dd":f=e?"päivän":"päivää";break;case"M":return e?"kuukauden":"kuukausi";case"MM":f=e?"kuukauden":"kuukautta";break;case"y":return e?"vuoden":"vuosi";case"yy":f=e?"vuoden":"vuotta"}return f=d(a,e)+" "+f}function d(a,b){return 10>a?b?f[a]:e[a]:a}var e="nolla yksi kaksi kolme neljä viisi kuusi seitsemän kahdeksan yhdeksän".split(" "),f=["nolla","yhden","kahden","kolmen","neljän","viiden","kuuden",e[7],e[8],e[9]];(b.defineLocale||b.lang).call(b,"fi",{months:"tammikuu_helmikuu_maaliskuu_huhtikuu_toukokuu_kesäkuu_heinäkuu_elokuu_syyskuu_lokakuu_marraskuu_joulukuu".split("_"),monthsShort:"tammi_helmi_maalis_huhti_touko_kesä_heinä_elo_syys_loka_marras_joulu".split("_"),weekdays:"sunnuntai_maanantai_tiistai_keskiviikko_torstai_perjantai_lauantai".split("_"),weekdaysShort:"su_ma_ti_ke_to_pe_la".split("_"),weekdaysMin:"su_ma_ti_ke_to_pe_la".split("_"),longDateFormat:{LT:"HH.mm",LTS:"HH.mm.ss",L:"DD.MM.YYYY",LL:"Do MMMM[ta] YYYY",LLL:"Do MMMM[ta] YYYY, [klo] LT",LLLL:"dddd, Do MMMM[ta] YYYY, [klo] LT",l:"D.M.YYYY",ll:"Do MMM YYYY",lll:"Do MMM YYYY, [klo] LT",llll:"ddd, Do MMM YYYY, [klo] LT"},calendar:{sameDay:"[tänään] [klo] LT",nextDay:"[huomenna] [klo] LT",nextWeek:"dddd [klo] LT",lastDay:"[eilen] [klo] LT",lastWeek:"[viime] dddd[na] [klo] LT",sameElse:"L"},relativeTime:{future:"%s päästä",past:"%s sitten",s:c,m:c,mm:c,h:c,hh:c,d:c,dd:c,M:c,MM:c,y:c,yy:c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("fi","fi",{closeText:"Sulje",prevText:"&#xAB;Edellinen",nextText:"Seuraava&#xBB;",currentText:"Tänään",monthNames:["Tammikuu","Helmikuu","Maaliskuu","Huhtikuu","Toukokuu","Kesäkuu","Heinäkuu","Elokuu","Syyskuu","Lokakuu","Marraskuu","Joulukuu"],monthNamesShort:["Tammi","Helmi","Maalis","Huhti","Touko","Kesä","Heinä","Elo","Syys","Loka","Marras","Joulu"],dayNamesShort:["Su","Ma","Ti","Ke","To","Pe","La"],dayNames:["Sunnuntai","Maanantai","Tiistai","Keskiviikko","Torstai","Perjantai","Lauantai"],dayNamesMin:["Su","Ma","Ti","Ke","To","Pe","La"],weekHeader:"Vk",dateFormat:"d.m.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("fi",{buttonText:{month:"Kuukausi",week:"Viikko",day:"Päivä",list:"Tapahtumat"},allDayText:"Koko päivä",eventLimitText:"lisää"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"fr",{months:"janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split("_"),monthsShort:"janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split("_"),weekdays:"dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi".split("_"),weekdaysShort:"dim._lun._mar._mer._jeu._ven._sam.".split("_"),weekdaysMin:"Di_Lu_Ma_Me_Je_Ve_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Aujourd'hui à] LT",nextDay:"[Demain à] LT",nextWeek:"dddd [à] LT",lastDay:"[Hier à] LT",lastWeek:"dddd [dernier à] LT",sameElse:"L"},relativeTime:{future:"dans %s",past:"il y a %s",s:"quelques secondes",m:"une minute",mm:"%d minutes",h:"une heure",hh:"%d heures",d:"un jour",dd:"%d jours",M:"un mois",MM:"%d mois",y:"un an",yy:"%d ans"},ordinalParse:/\d{1,2}(er|)/,ordinal:function(a){return a+(1===a?"er":"")},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("fr","fr",{closeText:"Fermer",prevText:"Précédent",nextText:"Suivant",currentText:"Aujourd'hui",monthNames:["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],monthNamesShort:["janv.","févr.","mars","avr.","mai","juin","juil.","août","sept.","oct.","nov.","déc."],dayNames:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"],dayNamesShort:["dim.","lun.","mar.","mer.","jeu.","ven.","sam."],dayNamesMin:["D","L","M","M","J","V","S"],weekHeader:"Sem.",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("fr",{buttonText:{month:"Mois",week:"Semaine",day:"Jour",list:"Mon planning"},allDayHtml:"Toute la<br/>journée",eventLimitText:"en plus"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b,c,d){var e=a;switch(c){case"s":return d||b?"néhány másodperc":"néhány másodperce";case"m":return"egy"+(d||b?" perc":" perce");case"mm":return e+(d||b?" perc":" perce");case"h":return"egy"+(d||b?" óra":" órája");case"hh":return e+(d||b?" óra":" órája");case"d":return"egy"+(d||b?" nap":" napja");case"dd":return e+(d||b?" nap":" napja");case"M":return"egy"+(d||b?" hónap":" hónapja");case"MM":return e+(d||b?" hónap":" hónapja");case"y":return"egy"+(d||b?" év":" éve");case"yy":return e+(d||b?" év":" éve")}return""}function d(a){return(a?"":"[múlt] ")+"["+e[this.day()]+"] LT[-kor]"}var e="vasárnap hétfőn kedden szerdán csütörtökön pénteken szombaton".split(" ");(b.defineLocale||b.lang).call(b,"hu",{months:"január_február_március_április_május_június_július_augusztus_szeptember_október_november_december".split("_"),monthsShort:"jan_feb_márc_ápr_máj_jún_júl_aug_szept_okt_nov_dec".split("_"),weekdays:"vasárnap_hétfő_kedd_szerda_csütörtök_péntek_szombat".split("_"),weekdaysShort:"vas_hét_kedd_sze_csüt_pén_szo".split("_"),weekdaysMin:"v_h_k_sze_cs_p_szo".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"YYYY.MM.DD.",LL:"YYYY. MMMM D.",LLL:"YYYY. MMMM D., LT",LLLL:"YYYY. MMMM D., dddd LT"},meridiemParse:/de|du/i,isPM:function(a){return"u"===a.charAt(1).toLowerCase()},meridiem:function(a,b,c){return 12>a?c===!0?"de":"DE":c===!0?"du":"DU"},calendar:{sameDay:"[ma] LT[-kor]",nextDay:"[holnap] LT[-kor]",nextWeek:function(){return d.call(this,!0)},lastDay:"[tegnap] LT[-kor]",lastWeek:function(){return d.call(this,!1)},sameElse:"L"},relativeTime:{future:"%s múlva",past:"%s",s:c,m:c,mm:c,h:c,hh:c,d:c,dd:c,M:c,MM:c,y:c,yy:c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("hu","hu",{closeText:"bezár",prevText:"vissza",nextText:"előre",currentText:"ma",monthNames:["Január","Február","Március","Április","Május","Június","Július","Augusztus","Szeptember","Október","November","December"],monthNamesShort:["Jan","Feb","Már","Ápr","Máj","Jún","Júl","Aug","Szep","Okt","Nov","Dec"],dayNames:["Vasárnap","Hétfő","Kedd","Szerda","Csütörtök","Péntek","Szombat"],dayNamesShort:["Vas","Hét","Ked","Sze","Csü","Pén","Szo"],dayNamesMin:["V","H","K","Sze","Cs","P","Szo"],weekHeader:"Hét",dateFormat:"yy.mm.dd.",firstDay:1,isRTL:!1,showMonthAfterYear:!0,yearSuffix:""}),a.fullCalendar.lang("hu",{buttonText:{month:"Hónap",week:"Hét",day:"Nap",list:"Napló"},allDayText:"Egész nap",eventLimitText:"további"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"id",{months:"Januari_Februari_Maret_April_Mei_Juni_Juli_Agustus_September_Oktober_November_Desember".split("_"),monthsShort:"Jan_Feb_Mar_Apr_Mei_Jun_Jul_Ags_Sep_Okt_Nov_Des".split("_"),weekdays:"Minggu_Senin_Selasa_Rabu_Kamis_Jumat_Sabtu".split("_"),weekdaysShort:"Min_Sen_Sel_Rab_Kam_Jum_Sab".split("_"),weekdaysMin:"Mg_Sn_Sl_Rb_Km_Jm_Sb".split("_"),longDateFormat:{LT:"HH.mm",LTS:"LT.ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY [pukul] LT",LLLL:"dddd, D MMMM YYYY [pukul] LT"},meridiemParse:/pagi|siang|sore|malam/,meridiemHour:function(a,b){return 12===a&&(a=0),"pagi"===b?a:"siang"===b?a>=11?a:a+12:"sore"===b||"malam"===b?a+12:void 0},meridiem:function(a,b,c){return 11>a?"pagi":15>a?"siang":19>a?"sore":"malam"},calendar:{sameDay:"[Hari ini pukul] LT",nextDay:"[Besok pukul] LT",nextWeek:"dddd [pukul] LT",lastDay:"[Kemarin pukul] LT",lastWeek:"dddd [lalu pukul] LT",sameElse:"L"},relativeTime:{future:"dalam %s",past:"%s yang lalu",s:"beberapa detik",m:"semenit",mm:"%d menit",h:"sejam",hh:"%d jam",d:"sehari",dd:"%d hari",M:"sebulan",MM:"%d bulan",y:"setahun",yy:"%d tahun"},week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("id","id",{closeText:"Tutup",prevText:"&#x3C;mundur",nextText:"maju&#x3E;",currentText:"hari ini",monthNames:["Januari","Februari","Maret","April","Mei","Juni","Juli","Agustus","September","Oktober","Nopember","Desember"],monthNamesShort:["Jan","Feb","Mar","Apr","Mei","Jun","Jul","Agus","Sep","Okt","Nop","Des"],dayNames:["Minggu","Senin","Selasa","Rabu","Kamis","Jumat","Sabtu"],dayNamesShort:["Min","Sen","Sel","Rab","kam","Jum","Sab"],dayNamesMin:["Mg","Sn","Sl","Rb","Km","jm","Sb"],weekHeader:"Mg",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("id",{buttonText:{month:"Bulan",week:"Minggu",day:"Hari",list:"Agenda"},allDayHtml:"Sehari<br/>penuh",eventLimitText:"lebih"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"it",{months:"gennaio_febbraio_marzo_aprile_maggio_giugno_luglio_agosto_settembre_ottobre_novembre_dicembre".split("_"),monthsShort:"gen_feb_mar_apr_mag_giu_lug_ago_set_ott_nov_dic".split("_"),weekdays:"Domenica_Lunedì_Martedì_Mercoledì_Giovedì_Venerdì_Sabato".split("_"),weekdaysShort:"Dom_Lun_Mar_Mer_Gio_Ven_Sab".split("_"),weekdaysMin:"D_L_Ma_Me_G_V_S".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Oggi alle] LT",nextDay:"[Domani alle] LT",nextWeek:"dddd [alle] LT",lastDay:"[Ieri alle] LT",lastWeek:function(){switch(this.day()){case 0:return"[la scorsa] dddd [alle] LT";default:return"[lo scorso] dddd [alle] LT"}},sameElse:"L"},relativeTime:{future:function(a){return(/^[0-9].+$/.test(a)?"tra":"in")+" "+a},past:"%s fa",s:"alcuni secondi",m:"un minuto",mm:"%d minuti",h:"un'ora",hh:"%d ore",d:"un giorno",dd:"%d giorni",M:"un mese",MM:"%d mesi",y:"un anno",yy:"%d anni"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("it","it",{closeText:"Chiudi",prevText:"&#x3C;Prec",nextText:"Succ&#x3E;",currentText:"Oggi",monthNames:["Gennaio","Febbraio","Marzo","Aprile","Maggio","Giugno","Luglio","Agosto","Settembre","Ottobre","Novembre","Dicembre"],monthNamesShort:["Gen","Feb","Mar","Apr","Mag","Giu","Lug","Ago","Set","Ott","Nov","Dic"],dayNames:["Domenica","Lunedì","Martedì","Mercoledì","Giovedì","Venerdì","Sabato"],dayNamesShort:["Dom","Lun","Mar","Mer","Gio","Ven","Sab"],dayNamesMin:["Do","Lu","Ma","Me","Gi","Ve","Sa"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("it",{buttonText:{month:"Mese",week:"Settimana",day:"Giorno",list:"Agenda"},allDayHtml:"Tutto il<br/>giorno",eventLimitText:function(a){return"+altri "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"ja",{months:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"日曜日_月曜日_火曜日_水曜日_木曜日_金曜日_土曜日".split("_"),weekdaysShort:"日_月_火_水_木_金_土".split("_"),weekdaysMin:"日_月_火_水_木_金_土".split("_"),longDateFormat:{LT:"Ah時m分",LTS:"LTs秒",L:"YYYY/MM/DD",LL:"YYYY年M月D日",LLL:"YYYY年M月D日LT",LLLL:"YYYY年M月D日LT dddd"},meridiemParse:/午前|午後/i,isPM:function(a){return"午後"===a},meridiem:function(a,b,c){return 12>a?"午前":"午後"},calendar:{sameDay:"[今日] LT",nextDay:"[明日] LT",nextWeek:"[来週]dddd LT",lastDay:"[昨日] LT",lastWeek:"[前週]dddd LT",sameElse:"L"},relativeTime:{future:"%s後",past:"%s前",s:"数秒",m:"1分",mm:"%d分",h:"1時間",hh:"%d時間",d:"1日",dd:"%d日",M:"1ヶ月",MM:"%dヶ月",y:"1年",yy:"%d年"}}),a.fullCalendar.datepickerLang("ja","ja",{closeText:"閉じる",prevText:"&#x3C;前",nextText:"次&#x3E;",currentText:"今日",monthNames:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],monthNamesShort:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayNames:["日曜日","月曜日","火曜日","水曜日","木曜日","金曜日","土曜日"],dayNamesShort:["日","月","火","水","木","金","土"],dayNamesMin:["日","月","火","水","木","金","土"],weekHeader:"週",dateFormat:"yy/mm/dd",firstDay:0,isRTL:!1,showMonthAfterYear:!0,yearSuffix:"年"}),a.fullCalendar.lang("ja",{buttonText:{month:"月",week:"週",day:"日",list:"予定リスト"},allDayText:"終日",eventLimitText:function(a){return"他 "+a+" 件"}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c="jan._feb._mrt._apr._mei_jun._jul._aug._sep._okt._nov._dec.".split("_"),d="jan_feb_mrt_apr_mei_jun_jul_aug_sep_okt_nov_dec".split("_");(b.defineLocale||b.lang).call(b,"nl",{months:"januari_februari_maart_april_mei_juni_juli_augustus_september_oktober_november_december".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?d[a.month()]:c[a.month()]},weekdays:"zondag_maandag_dinsdag_woensdag_donderdag_vrijdag_zaterdag".split("_"),weekdaysShort:"zo._ma._di._wo._do._vr._za.".split("_"),weekdaysMin:"Zo_Ma_Di_Wo_Do_Vr_Za".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD-MM-YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[vandaag om] LT",nextDay:"[morgen om] LT",nextWeek:"dddd [om] LT",lastDay:"[gisteren om] LT",lastWeek:"[afgelopen] dddd [om] LT",sameElse:"L"},relativeTime:{future:"over %s",past:"%s geleden",s:"een paar seconden",m:"één minuut",mm:"%d minuten",h:"één uur",hh:"%d uur",d:"één dag",dd:"%d dagen",M:"één maand",MM:"%d maanden",y:"één jaar",yy:"%d jaar"},ordinalParse:/\d{1,2}(ste|de)/,ordinal:function(a){return a+(1===a||8===a||a>=20?"ste":"de")},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("nl","nl",{closeText:"Sluiten",prevText:"←",nextText:"→",currentText:"Vandaag",monthNames:["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"],monthNamesShort:["jan","feb","mrt","apr","mei","jun","jul","aug","sep","okt","nov","dec"],dayNames:["zondag","maandag","dinsdag","woensdag","donderdag","vrijdag","zaterdag"],dayNamesShort:["zon","maa","din","woe","don","vri","zat"],dayNamesMin:["zo","ma","di","wo","do","vr","za"],weekHeader:"Wk",dateFormat:"dd-mm-yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("nl",{buttonText:{month:"Maand",week:"Week",day:"Dag",list:"Agenda"},allDayText:"Hele dag",eventLimitText:"extra"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"nb",{months:"januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split("_"),monthsShort:"jan_feb_mar_apr_mai_jun_jul_aug_sep_okt_nov_des".split("_"),weekdays:"søndag_mandag_tirsdag_onsdag_torsdag_fredag_lørdag".split("_"),weekdaysShort:"søn_man_tirs_ons_tors_fre_lør".split("_"),weekdaysMin:"sø_ma_ti_on_to_fr_lø".split("_"),longDateFormat:{LT:"H.mm",LTS:"LT.ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY [kl.] LT",LLLL:"dddd D. MMMM YYYY [kl.] LT"},calendar:{sameDay:"[i dag kl.] LT",nextDay:"[i morgen kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[i går kl.] LT",lastWeek:"[forrige] dddd [kl.] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"for %s siden",s:"noen sekunder",m:"ett minutt",mm:"%d minutter",h:"en time",hh:"%d timer",d:"en dag",dd:"%d dager",M:"en måned",MM:"%d måneder",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("nb","nb",{closeText:"Lukk",prevText:"&#xAB;Forrige",nextText:"Neste&#xBB;",currentText:"I dag",monthNames:["januar","februar","mars","april","mai","juni","juli","august","september","oktober","november","desember"],monthNamesShort:["jan","feb","mar","apr","mai","jun","jul","aug","sep","okt","nov","des"],dayNamesShort:["søn","man","tir","ons","tor","fre","lør"],dayNames:["søndag","mandag","tirsdag","onsdag","torsdag","fredag","lørdag"],dayNamesMin:["sø","ma","ti","on","to","fr","lø"],weekHeader:"Uke",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("nb",{buttonText:{month:"Måned",week:"Uke",day:"Dag",list:"Agenda"},allDayText:"Hele dagen",eventLimitText:"til"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a){return 5>a%10&&a%10>1&&~~(a/10)%10!==1}function d(a,b,d){var e=a+" ";switch(d){case"m":return b?"minuta":"minutę";case"mm":return e+(c(a)?"minuty":"minut");case"h":return b?"godzina":"godzinę";case"hh":return e+(c(a)?"godziny":"godzin");case"MM":return e+(c(a)?"miesiące":"miesięcy");case"yy":return e+(c(a)?"lata":"lat")}}var e="styczeń_luty_marzec_kwiecień_maj_czerwiec_lipiec_sierpień_wrzesień_październik_listopad_grudzień".split("_"),f="stycznia_lutego_marca_kwietnia_maja_czerwca_lipca_sierpnia_września_października_listopada_grudnia".split("_");(b.defineLocale||b.lang).call(b,"pl",{months:function(a,b){return/D MMMM/.test(b)?f[a.month()]:e[a.month()]},monthsShort:"sty_lut_mar_kwi_maj_cze_lip_sie_wrz_paź_lis_gru".split("_"),weekdays:"niedziela_poniedziałek_wtorek_środa_czwartek_piątek_sobota".split("_"),weekdaysShort:"nie_pon_wt_śr_czw_pt_sb".split("_"),weekdaysMin:"N_Pn_Wt_Śr_Cz_Pt_So".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Dziś o] LT",nextDay:"[Jutro o] LT",nextWeek:"[W] dddd [o] LT",lastDay:"[Wczoraj o] LT",lastWeek:function(){switch(this.day()){case 0:return"[W zeszłą niedzielę o] LT";case 3:return"[W zeszłą środę o] LT";case 6:return"[W zeszłą sobotę o] LT";default:return"[W zeszły] dddd [o] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"%s temu",s:"kilka sekund",m:d,mm:d,h:d,hh:d,d:"1 dzień",dd:"%d dni",M:"miesiąc",MM:d,y:"rok",yy:d},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("pl","pl",{closeText:"Zamknij",prevText:"&#x3C;Poprzedni",nextText:"Następny&#x3E;",currentText:"Dziś",monthNames:["Styczeń","Luty","Marzec","Kwiecień","Maj","Czerwiec","Lipiec","Sierpień","Wrzesień","Październik","Listopad","Grudzień"],monthNamesShort:["Sty","Lu","Mar","Kw","Maj","Cze","Lip","Sie","Wrz","Pa","Lis","Gru"],dayNames:["Niedziela","Poniedziałek","Wtorek","Środa","Czwartek","Piątek","Sobota"],dayNamesShort:["Nie","Pn","Wt","Śr","Czw","Pt","So"],dayNamesMin:["N","Pn","Wt","Śr","Cz","Pt","So"],weekHeader:"Tydz",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("pl",{buttonText:{month:"Miesiąc",week:"Tydzień",day:"Dzień",list:"Plan dnia"},allDayText:"Cały dzień",eventLimitText:"więcej"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"pt",{months:"janeiro_fevereiro_março_abril_maio_junho_julho_agosto_setembro_outubro_novembro_dezembro".split("_"),monthsShort:"jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez".split("_"),weekdays:"domingo_segunda-feira_terça-feira_quarta-feira_quinta-feira_sexta-feira_sábado".split("_"),weekdaysShort:"dom_seg_ter_qua_qui_sex_sáb".split("_"),weekdaysMin:"dom_2ª_3ª_4ª_5ª_6ª_sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY LT",LLLL:"dddd, D [de] MMMM [de] YYYY LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"há %s",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("pt","pt",{closeText:"Fechar",prevText:"Anterior",nextText:"Seguinte",currentText:"Hoje",monthNames:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthNamesShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],dayNames:["Domingo","Segunda-feira","Terça-feira","Quarta-feira","Quinta-feira","Sexta-feira","Sábado"],dayNamesShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],dayNamesMin:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],weekHeader:"Sem",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("pt",{buttonText:{month:"Mês",week:"Semana",day:"Dia",list:"Agenda"},allDayText:"Todo o dia",eventLimitText:"mais"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"pt-br",{months:"janeiro_fevereiro_março_abril_maio_junho_julho_agosto_setembro_outubro_novembro_dezembro".split("_"),monthsShort:"jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez".split("_"),weekdays:"domingo_segunda-feira_terça-feira_quarta-feira_quinta-feira_sexta-feira_sábado".split("_"),weekdaysShort:"dom_seg_ter_qua_qui_sex_sáb".split("_"),weekdaysMin:"dom_2ª_3ª_4ª_5ª_6ª_sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY [às] LT",LLLL:"dddd, D [de] MMMM [de] YYYY [às] LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"%s atrás",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº"}),a.fullCalendar.datepickerLang("pt-br","pt-BR",{closeText:"Fechar",prevText:"&#x3C;Anterior",nextText:"Próximo&#x3E;",currentText:"Hoje",monthNames:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthNamesShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],dayNames:["Domingo","Segunda-feira","Terça-feira","Quarta-feira","Quinta-feira","Sexta-feira","Sábado"],dayNamesShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],dayNamesMin:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("pt-br",{buttonText:{month:"Mês",week:"Semana",day:"Dia",list:"Compromissos"},allDayText:"dia inteiro",eventLimitText:function(a){return"mais +"+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b){var c=a.split("_");return b%10===1&&b%100!==11?c[0]:b%10>=2&&4>=b%10&&(10>b%100||b%100>=20)?c[1]:c[2]}function d(a,b,d){var e={mm:b?"минута_минуты_минут":"минуту_минуты_минут",hh:"час_часа_часов",dd:"день_дня_дней",MM:"месяц_месяца_месяцев",yy:"год_года_лет"};return"m"===d?b?"минута":"минуту":a+" "+c(e[d],+a)}function e(a,b){var c={nominative:"январь_февраль_март_апрель_май_июнь_июль_август_сентябрь_октябрь_ноябрь_декабрь".split("_"),accusative:"января_февраля_марта_апреля_мая_июня_июля_августа_сентября_октября_ноября_декабря".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function f(a,b){var c={nominative:"янв_фев_март_апр_май_июнь_июль_авг_сен_окт_ноя_дек".split("_"),accusative:"янв_фев_мар_апр_мая_июня_июля_авг_сен_окт_ноя_дек".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function g(a,b){var c={nominative:"воскресенье_понедельник_вторник_среда_четверг_пятница_суббота".split("_"),accusative:"воскресенье_понедельник_вторник_среду_четверг_пятницу_субботу".split("_")},d=/\[ ?[Вв] ?(?:прошлую|следующую|эту)? ?\] ?dddd/.test(b)?"accusative":"nominative";return c[d][a.day()]}(b.defineLocale||b.lang).call(b,"ru",{months:e,monthsShort:f,weekdays:g,weekdaysShort:"вс_пн_вт_ср_чт_пт_сб".split("_"),weekdaysMin:"вс_пн_вт_ср_чт_пт_сб".split("_"),monthsParse:[/^янв/i,/^фев/i,/^мар/i,/^апр/i,/^ма[й|я]/i,/^июн/i,/^июл/i,/^авг/i,/^сен/i,/^окт/i,/^ноя/i,/^дек/i],longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY г.",LLL:"D MMMM YYYY г., LT",LLLL:"dddd, D MMMM YYYY г., LT"},calendar:{sameDay:"[Сегодня в] LT",nextDay:"[Завтра в] LT",lastDay:"[Вчера в] LT",nextWeek:function(){return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT"},lastWeek:function(a){if(a.week()===this.week())return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT";switch(this.day()){case 0:return"[В прошлое] dddd [в] LT";case 1:case 2:case 4:return"[В прошлый] dddd [в] LT";case 3:case 5:case 6:return"[В прошлую] dddd [в] LT"}},sameElse:"L"},relativeTime:{future:"через %s",past:"%s назад",s:"несколько секунд",m:d,mm:d,h:"час",hh:d,d:"день",dd:d,M:"месяц",MM:d,y:"год",yy:d},meridiemParse:/ночи|утра|дня|вечера/i,isPM:function(a){return/^(дня|вечера)$/.test(a)},meridiem:function(a,b,c){return 4>a?"ночи":12>a?"утра":17>a?"дня":"вечера"},ordinalParse:/\d{1,2}-(й|го|я)/,ordinal:function(a,b){switch(b){case"M":case"d":case"DDD":return a+"-й";case"D":return a+"-го";case"w":case"W":return a+"-я";default:return a}},week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("ru","ru",{closeText:"Закрыть",prevText:"&#x3C;Пред",nextText:"След&#x3E;",currentText:"Сегодня",monthNames:["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"],monthNamesShort:["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"],dayNames:["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"],dayNamesShort:["вск","пнд","втр","срд","чтв","птн","сбт"],dayNamesMin:["Вс","Пн","Вт","Ср","Чт","Пт","Сб"],weekHeader:"Нед",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("ru",{buttonText:{month:"Месяц",week:"Неделя",day:"День",list:"Повестка дня"},allDayText:"Весь день",eventLimitText:function(a){return"+ ещё "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"sv",{months:"januari_februari_mars_april_maj_juni_juli_augusti_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"söndag_måndag_tisdag_onsdag_torsdag_fredag_lördag".split("_"),weekdaysShort:"sön_mån_tis_ons_tor_fre_lör".split("_"),weekdaysMin:"sö_må_ti_on_to_fr_lö".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Idag] LT",nextDay:"[Imorgon] LT",lastDay:"[Igår] LT",nextWeek:"dddd LT",lastWeek:"[Förra] dddd[en] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"för %s sedan",s:"några sekunder",m:"en minut",mm:"%d minuter",h:"en timme",hh:"%d timmar",d:"en dag",dd:"%d dagar",M:"en månad",MM:"%d månader",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}(e|a)/,ordinal:function(a){var b=a%10,c=1===~~(a%100/10)?"e":1===b?"a":2===b?"a":"e";return a+c},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("sv","sv",{closeText:"Stäng",prevText:"&#xAB;Förra",nextText:"Nästa&#xBB;",currentText:"Idag",monthNames:["Januari","Februari","Mars","April","Maj","Juni","Juli","Augusti","September","Oktober","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],dayNamesShort:["Sön","Mån","Tis","Ons","Tor","Fre","Lör"],dayNames:["Söndag","Måndag","Tisdag","Onsdag","Torsdag","Fredag","Lördag"],dayNamesMin:["Sö","Må","Ti","On","To","Fr","Lö"],weekHeader:"Ve",dateFormat:"yy-mm-dd",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("sv",{buttonText:{month:"Månad",week:"Vecka",day:"Dag",list:"Program"},allDayText:"Heldag",eventLimitText:"till"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c={words:{m:["jedan minut","jedne minute"],mm:["minut","minute","minuta"],h:["jedan sat","jednog sata"],hh:["sat","sata","sati"],dd:["dan","dana","dana"],MM:["mesec","meseca","meseci"],yy:["godina","godine","godina"]},correctGrammaticalCase:function(a,b){return 1===a?b[0]:a>=2&&4>=a?b[1]:b[2]},translate:function(a,b,d){var e=c.words[d];return 1===d.length?b?e[0]:e[1]:a+" "+c.correctGrammaticalCase(a,e)}};(b.defineLocale||b.lang).call(b,"sr",{months:["januar","februar","mart","april","maj","jun","jul","avgust","septembar","oktobar","novembar","decembar"],monthsShort:["jan.","feb.","mar.","apr.","maj","jun","jul","avg.","sep.","okt.","nov.","dec."],weekdays:["nedelja","ponedeljak","utorak","sreda","četvrtak","petak","subota"],weekdaysShort:["ned.","pon.","uto.","sre.","čet.","pet.","sub."],weekdaysMin:["ne","po","ut","sr","če","pe","su"],longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedelju] [u] LT";case 3:return"[u] [sredu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[juče u] LT",lastWeek:function(){var a=["[prošle] [nedelje] [u] LT","[prošlog] [ponedeljka] [u] LT","[prošlog] [utorka] [u] LT","[prošle] [srede] [u] LT","[prošlog] [četvrtka] [u] LT","[prošlog] [petka] [u] LT","[prošle] [subote] [u] LT"];return a[this.day()]},sameElse:"L"},relativeTime:{future:"za %s",past:"pre %s",s:"nekoliko sekundi",m:c.translate,mm:c.translate,h:c.translate,hh:c.translate,d:"dan",dd:c.translate,M:"mesec",MM:c.translate,y:"godinu",yy:c.translate},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("sr","sr",{closeText:"Затвори",prevText:"&#x3C;",nextText:"&#x3E;",currentText:"Данас",monthNames:["Јануар","Фебруар","Март","Април","Мај","Јун","Јул","Август","Септембар","Октобар","Новембар","Децембар"],monthNamesShort:["Јан","Феб","Мар","Апр","Мај","Јун","Јул","Авг","Сеп","Окт","Нов","Дец"],dayNames:["Недеља","Понедељак","Уторак","Среда","Четвртак","Петак","Субота"],dayNamesShort:["Нед","Пон","Уто","Сре","Чет","Пет","Суб"],dayNamesMin:["Не","По","Ут","Ср","Че","Пе","Су"],weekHeader:"Сед",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("sr",{buttonText:{month:"Месец",week:"Недеља",day:"Дан",list:"Планер"},allDayText:"Цео дан",eventLimitText:function(a){return"+ још "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"th",{months:"มกราคม_กุมภาพันธ์_มีนาคม_เมษายน_พฤษภาคม_มิถุนายน_กรกฎาคม_สิงหาคม_กันยายน_ตุลาคม_พฤศจิกายน_ธันวาคม".split("_"),monthsShort:"มกรา_กุมภา_มีนา_เมษา_พฤษภา_มิถุนา_กรกฎา_สิงหา_กันยา_ตุลา_พฤศจิกา_ธันวา".split("_"),weekdays:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัสบดี_ศุกร์_เสาร์".split("_"),weekdaysShort:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัส_ศุกร์_เสาร์".split("_"),weekdaysMin:"อา._จ._อ._พ._พฤ._ศ._ส.".split("_"),longDateFormat:{LT:"H นาฬิกา m นาที",LTS:"LT s วินาที",L:"YYYY/MM/DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY เวลา LT",LLLL:"วันddddที่ D MMMM YYYY เวลา LT"},meridiemParse:/ก่อนเที่ยง|หลังเที่ยง/,isPM:function(a){return"หลังเที่ยง"===a},meridiem:function(a,b,c){return 12>a?"ก่อนเที่ยง":"หลังเที่ยง"},calendar:{sameDay:"[วันนี้ เวลา] LT",nextDay:"[พรุ่งนี้ เวลา] LT",nextWeek:"dddd[หน้า เวลา] LT",lastDay:"[เมื่อวานนี้ เวลา] LT",lastWeek:"[วัน]dddd[ที่แล้ว เวลา] LT",sameElse:"L"},relativeTime:{future:"อีก %s",past:"%sที่แล้ว",s:"ไม่กี่วินาที",m:"1 นาที",mm:"%d นาที",h:"1 ชั่วโมง",hh:"%d ชั่วโมง",d:"1 วัน",dd:"%d วัน",M:"1 เดือน",MM:"%d เดือน",y:"1 ปี",yy:"%d ปี"}}),a.fullCalendar.datepickerLang("th","th",{closeText:"ปิด",prevText:"&#xAB;&#xA0;ย้อน",nextText:"ถัดไป&#xA0;&#xBB;",currentText:"วันนี้",monthNames:["มกราคม","กุมภาพันธ์","มีนาคม","เมษายน","พฤษภาคม","มิถุนายน","กรกฎาคม","สิงหาคม","กันยายน","ตุลาคม","พฤศจิกายน","ธันวาคม"],monthNamesShort:["ม.ค.","ก.พ.","มี.ค.","เม.ย.","พ.ค.","มิ.ย.","ก.ค.","ส.ค.","ก.ย.","ต.ค.","พ.ย.","ธ.ค."],dayNames:["อาทิตย์","จันทร์","อังคาร","พุธ","พฤหัสบดี","ศุกร์","เสาร์"],dayNamesShort:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],dayNamesMin:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],weekHeader:"Wk",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("th",{buttonText:{month:"เดือน",week:"สัปดาห์",day:"วัน",list:"แผนงาน"},allDayText:"ตลอดวัน",eventLimitText:"เพิ่มเติม"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c={1:"'inci",5:"'inci",8:"'inci",70:"'inci",80:"'inci",2:"'nci",7:"'nci",20:"'nci",50:"'nci",3:"'üncü",4:"'üncü",100:"'üncü",6:"'ncı",9:"'uncu",10:"'uncu",30:"'uncu",60:"'ıncı",90:"'ıncı"};(b.defineLocale||b.lang).call(b,"tr",{months:"Ocak_Şubat_Mart_Nisan_Mayıs_Haziran_Temmuz_Ağustos_Eylül_Ekim_Kasım_Aralık".split("_"),monthsShort:"Oca_Şub_Mar_Nis_May_Haz_Tem_Ağu_Eyl_Eki_Kas_Ara".split("_"),weekdays:"Pazar_Pazartesi_Salı_Çarşamba_Perşembe_Cuma_Cumartesi".split("_"),weekdaysShort:"Paz_Pts_Sal_Çar_Per_Cum_Cts".split("_"),weekdaysMin:"Pz_Pt_Sa_Ça_Pe_Cu_Ct".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[bugün saat] LT",nextDay:"[yarın saat] LT",nextWeek:"[haftaya] dddd [saat] LT",lastDay:"[dün] LT",lastWeek:"[geçen hafta] dddd [saat] LT",sameElse:"L"},relativeTime:{future:"%s sonra",past:"%s önce",s:"birkaç saniye",m:"bir dakika",mm:"%d dakika",h:"bir saat",hh:"%d saat",d:"bir gün",dd:"%d gün",M:"bir ay",MM:"%d ay",y:"bir yıl",yy:"%d yıl"},ordinalParse:/\d{1,2}'(inci|nci|üncü|ncı|uncu|ıncı)/,ordinal:function(a){if(0===a)return a+"'ıncı";var b=a%10,d=a%100-b,e=a>=100?100:null;return a+(c[b]||c[d]||c[e])},week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("tr","tr",{closeText:"kapat",prevText:"&#x3C;geri",nextText:"ileri&#x3e",currentText:"bugün",monthNames:["Ocak","Şubat","Mart","Nisan","Mayıs","Haziran","Temmuz","Ağustos","Eylül","Ekim","Kasım","Aralık"],monthNamesShort:["Oca","Şub","Mar","Nis","May","Haz","Tem","Ağu","Eyl","Eki","Kas","Ara"],dayNames:["Pazar","Pazartesi","Salı","Çarşamba","Perşembe","Cuma","Cumartesi"],dayNamesShort:["Pz","Pt","Sa","Ça","Pe","Cu","Ct"],dayNamesMin:["Pz","Pt","Sa","Ça","Pe","Cu","Ct"],weekHeader:"Hf",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("tr",{buttonText:{next:"ileri",month:"Ay",week:"Hafta",day:"Gün",list:"Ajanda"},allDayText:"Tüm gün",eventLimitText:"daha fazla"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"zh-cn",{months:"一月_二月_三月_四月_五月_六月_七月_八月_九月_十月_十一月_十二月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"星期日_星期一_星期二_星期三_星期四_星期五_星期六".split("_"),weekdaysShort:"周日_周一_周二_周三_周四_周五_周六".split("_"),weekdaysMin:"日_一_二_三_四_五_六".split("_"),longDateFormat:{LT:"Ah点mm",LTS:"Ah点m分s秒",L:"YYYY-MM-DD",LL:"YYYY年MMMD日",LLL:"YYYY年MMMD日LT",LLLL:"YYYY年MMMD日ddddLT",l:"YYYY-MM-DD",ll:"YYYY年MMMD日",lll:"YYYY年MMMD日LT",llll:"YYYY年MMMD日ddddLT"},meridiemParse:/凌晨|早上|上午|中午|下午|晚上/,meridiemHour:function(a,b){return 12===a&&(a=0),"凌晨"===b||"早上"===b||"上午"===b?a:"下午"===b||"晚上"===b?a+12:a>=11?a:a+12},meridiem:function(a,b,c){var d=100*a+b;return 600>d?"凌晨":900>d?"早上":1130>d?"上午":1230>d?"中午":1800>d?"下午":"晚上"},calendar:{sameDay:function(){return 0===this.minutes()?"[今天]Ah[点整]":"[今天]LT"},nextDay:function(){return 0===this.minutes()?"[明天]Ah[点整]":"[明天]LT"},lastDay:function(){return 0===this.minutes()?"[昨天]Ah[点整]":"[昨天]LT"},nextWeek:function(){var a,c;return a=b().startOf("week"),c=this.unix()-a.unix()>=604800?"[下]":"[本]",0===this.minutes()?c+"dddAh点整":c+"dddAh点mm"},lastWeek:function(){var a,c;return a=b().startOf("week"),c=this.unix()<a.unix()?"[上]":"[本]",0===this.minutes()?c+"dddAh点整":c+"dddAh点mm"},sameElse:"LL"},ordinalParse:/\d{1,2}(日|月|周)/,ordinal:function(a,b){switch(b){case"d":case"D":case"DDD":return a+"日";case"M":return a+"月";case"w":case"W":return a+"周";default:return a}},relativeTime:{future:"%s内",past:"%s前",s:"几秒",m:"1分钟",mm:"%d分钟",h:"1小时",hh:"%d小时",d:"1天",dd:"%d天",M:"1个月",MM:"%d个月",y:"1年",yy:"%d年"},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("zh-cn","zh-CN",{closeText:"关闭",prevText:"&#x3C;上月",nextText:"下月&#x3E;",currentText:"今天",monthNames:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthNamesShort:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],dayNames:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayNamesShort:["周日","周一","周二","周三","周四","周五","周六"],dayNamesMin:["日","一","二","三","四","五","六"],weekHeader:"周",dateFormat:"yy-mm-dd",firstDay:1,isRTL:!1,showMonthAfterYear:!0,yearSuffix:"年"}),a.fullCalendar.lang("zh-cn",{buttonText:{month:"月",week:"周",day:"日",list:"日程"},allDayText:"全天",eventLimitText:function(a){return"另外 "+a+" 个"}})});(function(){function r(v){this.app=v;this.router=new t();this.router.addRoute("screenshot-zone",e)}r.prototype.isOpen=function(){return $("#popover-container").size()>0};r.prototype.open=function(w){var v=this;v.app.dropdown.close();$.get(w,function(x){$("body").append('<div id="popover-container"><div id="popover-content">'+x+"</div></div>");v.app.refresh();v.router.dispatch(this.app);v.afterOpen()})};r.prototype.close=function(v){if(this.isOpen()){if(v){v.preventDefault()}$("#popover-container").remove()}};r.prototype.onClick=function(w){w.preventDefault();w.stopPropagation();var v=w.target.getAttribute("href");if(!v){v=w.target.getAttribute("data-href")}if(v){this.open(v)}};r.prototype.listen=function(){$(document).on("click",".popover",this.onClick.bind(this));$(document).on("click",".close-popover",this.close.bind(this));$(document).on("click","#popover-container",this.close.bind(this));$(document).on("click","#popover-content",function(v){v.stopPropagation()})};r.prototype.afterOpen=function(){var v=this;var w=$("#task-form");if(w){w.on("submit",function(x){x.preventDefault();$.ajax({type:"POST",url:w.attr("action"),data:w.serialize(),success:function(z,A,y){if(y.getResponseHeader("X-Ajax-Redirect")){window.location=y.getResponseHeader("X-Ajax-Redirect")}else{$("#popover-content").html(z);v.afterOpen()}}})})}};function p(){}p.prototype.listen=function(){var v=this;$(document).on("click",function(){v.close()});$(document).on("click",".dropdown-menu",function(z){z.preventDefault();z.stopImmediatePropagation();v.close();var x=$(this).next("ul");var y=240;var A=$(this).offset();var w=$(this).height();$("body").append(jQuery("<div>",{id:"dropdown"}));x.clone().appendTo("#dropdown");var B=$("#dropdown ul");B.css("left",A.left);if(A.top+y-$(window).scrollTop()>$(window).height()){B.css("top",A.top-y-w)}else{B.css("top",A.top+w)}B.addClass("dropdown-submenu-open")})};p.prototype.close=function(){$("#dropdown").remove()};function o(v){this.app=v}o.prototype.listen=function(){var v=this;$(".tooltip").tooltip({track:false,show:false,hide:false,position:{my:"left-20 top",at:"center bottom+9",using:function(w,x){$(this).css(w);var y=x.target.left+x.target.width/2-x.element.left-20;$("<div>").addClass("tooltip-arrow").addClass(x.vertical).addClass(y<1?"align-left":"align-right").appendTo(this)}},content:function(){var y=this;var w=$(this).attr("data-href");if(!w){return'<div class="markdown">'+$(this).attr("title")+"</div>"}$.get(w,function x(B){var A=$(".ui-tooltip:visible");$(".ui-tooltip-content:visible").html(B);A.css({top:"",left:""});A.children(".tooltip-arrow").remove();var z=$(y).tooltip("option","position");z.of=$(y);A.position(z);$("#tooltip-subtasks a").not(".popover").click(function(C){C.preventDefault();C.stopPropagation();if($(this).hasClass("popover-subtask-restriction")){v.app.popover.open($(this).attr("href"));$(y).tooltip("close")}else{$.get($(this).attr("href"),x)}})});return'<i class="fa fa-spinner fa-spin"></i>'}}).on("mouseenter",function(){var w=this;$(this).tooltip("open");$(".ui-tooltip").on("mouseleave",function(){$(w).tooltip("close")})}).on("mouseleave focusout",function(w){w.stopImmediatePropagation();var x=this;setTimeout(function(){if(!$(".ui-tooltip:hover").length){$(x).tooltip("close")}},100)})};function k(){}k.prototype.showPreview=function(z){z.preventDefault();var w=$(".write-area");var y=$(".preview-area");var v=$("textarea");$("#markdown-write").parent().removeClass("form-tab-selected");$("#markdown-preview").parent().addClass("form-tab-selected");var x=$.ajax({url:$("body").data("markdown-preview-url"),contentType:"application/json",type:"POST",processData:false,dataType:"html",data:JSON.stringify({text:v.val()})});x.done(function(A){y.find(".markdown").html(A);y.css("height",v.css("height"));y.css("width",v.css("width"));w.hide();y.show()})};k.prototype.showWriter=function(v){v.preventDefault();$("#markdown-write").parent().addClass("form-tab-selected");$("#markdown-preview").parent().removeClass("form-tab-selected");$(".write-area").show();$(".preview-area").hide()};k.prototype.listen=function(){$(document).on("click","#markdown-preview",this.showPreview.bind(this));$(document).on("click","#markdown-write",this.showWriter.bind(this))};function b(){}b.prototype.expand=function(v){v.preventDefault();$(".sidebar-container").removeClass("sidebar-collapsed");$(".sidebar-collapse").show();$(".sidebar h2").show();$(".sidebar ul").show();$(".sidebar-expand").hide()};b.prototype.collapse=function(v){v.preventDefault();$(".sidebar-container").addClass("sidebar-collapsed");$(".sidebar-expand").show();$(".sidebar h2").hide();$(".sidebar ul").hide();$(".sidebar-collapse").hide()};b.prototype.listen=function(){$(document).on("click",".sidebar-collapse",this.collapse);$(document).on("click",".sidebar-expand",this.expand)};function f(v){this.app=v;this.keyboardShortcuts()}f.prototype.focus=function(){$(document).on("focus","#form-search",function(){if($("#form-search")[0].setSelectionRange){$("#form-search")[0].setSelectionRange($("#form-search").val().length,$("#form-search").val().length)}})};f.prototype.listen=function(){var v=this;$(document).on("click",".filter-helper",function(y){y.preventDefault();var x=$(this).data("filter");var w=$(this).data("append-filter");if(w){x=$("#form-search").val()+" "+w}$("#form-search").val(x);if($("#board").length){v.app.board.reloadFilters(x)}else{$("form.search").submit()}})};f.prototype.keyboardShortcuts=function(){var v=this;Mousetrap.bind("v b",function(x){var w=$(".view-board");if(w.length){window.location=w.attr("href")}});Mousetrap.bind("v c",function(x){var w=$(".view-calendar");if(w.length){window.location=w.attr("href")}});Mousetrap.bind("v l",function(x){var w=$(".view-listing");if(w.length){window.location=w.attr("href")}});Mousetrap.bind("v g",function(x){var w=$(".view-gantt");if(w.length){window.location=w.attr("href")}});Mousetrap.bind("f",function(x){x.preventDefault();var w=document.getElementById("form-search");if(w){w.focus()}});Mousetrap.bind("r",function(x){x.preventDefault();var w=$(".filter-reset").data("filter");$("#form-search").val(w);if($("#board").length){v.app.board.reloadFilters(w)}else{$("form.search").submit()}})};function l(){this.board=new j(this);this.markdown=new k();this.sidebar=new b();this.search=new f(this);this.swimlane=new g();this.dropdown=new p();this.tooltip=new o(this);this.popover=new r(this);this.task=new a();this.keyboardShortcuts();this.chosen();this.poll();$(".alert-fade-out").delay(4000).fadeOut(800,function(){$(this).remove()});var v=false;$("select.task-reload-project-destination").change(function(){if(!v){$(".loading-icon").show();v=true;window.location=$(this).data("redirect").replace(/PROJECT_ID/g,$(this).val())}})}l.prototype.listen=function(){this.popover.listen();this.markdown.listen();this.sidebar.listen();this.tooltip.listen();this.dropdown.listen();this.search.listen();this.task.listen();this.swimlane.listen();this.search.focus();this.taskAutoComplete();this.datePicker();this.focus()};l.prototype.refresh=function(){$(document).off();this.listen()};l.prototype.focus=function(){$("[autofocus]").each(function(v,w){$(this).focus()});$(document).on("focus",".auto-select",function(){$(this).select()});$(document).on("mouseup",".auto-select",function(v){v.preventDefault()})};l.prototype.poll=function(){window.setInterval(this.checkSession,60000)};l.prototype.keyboardShortcuts=function(){var v=this;Mousetrap.bindGlobal("mod+enter",function(){$("form").submit()});Mousetrap.bind("b",function(w){w.preventDefault();$("#board-selector").trigger("chosen:open")});Mousetrap.bindGlobal("esc",function(){v.popover.close();v.dropdown.close()})};l.prototype.checkSession=function(){if(!$(".form-login").length){$.ajax({cache:false,url:$("body").data("status-url"),statusCode:{401:function(){window.location=$("body").data("login-url")}}})}};l.prototype.datePicker=function(){$.datepicker.setDefaults($.datepicker.regional[$("body").data("js-lang")]);$(".form-date").datepicker({showOtherMonths:true,selectOtherMonths:true,dateFormat:"yy-mm-dd",constrainInput:false});$(".form-datetime").datetimepicker({controlType:"select",oneLine:true,dateFormat:"yy-mm-dd",constrainInput:false});$(".hasDatepicker").on("blur",function(v){$(this).datepicker("hide")})};l.prototype.taskAutoComplete=function(){if($(".task-autocomplete").length){if($(".opposite_task_id").val()==""){$(".task-autocomplete").parent().find("input[type=submit]").attr("disabled","disabled")}$(".task-autocomplete").autocomplete({source:$(".task-autocomplete").data("search-url"),minLength:1,select:function(v,w){var x=$(".task-autocomplete").data("dst-field");$("input[name="+x+"]").val(w.item.id);$(".task-autocomplete").parent().find("input[type=submit]").removeAttr("disabled")}})}};l.prototype.chosen=function(){$(".chosen-select").chosen({width:"180px",no_results_text:$(".chosen-select").data("notfound"),disable_search_threshold:10});$(".select-auto-redirect").change(function(){var v=new RegExp($(this).data("redirect-regex"),"g");window.location=$(this).data("redirect-url").replace(v,$(this).val())})};l.prototype.showLoadingIcon=function(){$("body").append('<span id="app-loading-icon">&nbsp;<i class="fa fa-spinner fa-spin"></i></span>')};l.prototype.hideLoadingIcon=function(){$("#app-loading-icon").remove()};l.prototype.isVisible=function(){var v="";if(typeof document.hidden!=="undefined"){v="visibilityState"}else{if(typeof document.mozHidden!=="undefined"){v="mozVisibilityState"}else{if(typeof document.msHidden!=="undefined"){v="msVisibilityState"}else{if(typeof document.webkitHidden!=="undefined"){v="webkitVisibilityState"}}}}if(v!=""){return document[v]=="visible"}return true};l.prototype.formatDuration=function(v){if(v>=86400){return Math.round(v/86400)+"d"}else{if(v>=3600){return Math.round(v/3600)+"h"}else{if(v>=60){return Math.round(v/60)+"m"}}}return v+"s"};function e(){this.pasteCatcher=null}e.prototype.execute=function(){this.initialize()};e.prototype.initialize=function(){this.destroy();if(!window.Clipboard){this.pasteCatcher=document.createElement("div");this.pasteCatcher.id="screenshot-pastezone";this.pasteCatcher.contentEditable="true";this.pasteCatcher.style.opacity=0;this.pasteCatcher.style.position="fixed";this.pasteCatcher.style.top=0;this.pasteCatcher.style.right=0;this.pasteCatcher.style.width=0;document.body.insertBefore(this.pasteCatcher,document.body.firstChild);this.pasteCatcher.focus();document.addEventListener("click",this.setFocus.bind(this));document.getElementById("screenshot-zone").addEventListener("click",this.setFocus.bind(this))}window.addEventListener("paste",this.pasteHandler.bind(this))};e.prototype.destroy=function(){if(this.pasteCatcher!=null){document.body.removeChild(this.pasteCatcher)}else{if(document.getElementById("screenshot-pastezone")){document.body.removeChild(document.getElementById("screenshot-pastezone"))}}document.removeEventListener("click",this.setFocus.bind(this));this.pasteCatcher=null};e.prototype.setFocus=function(){if(this.pasteCatcher!==null){this.pasteCatcher.focus()}};e.prototype.pasteHandler=function(A){if(A.clipboardData&&A.clipboardData.items){var y=A.clipboardData.items;if(y){for(var z=0;z<y.length;z++){if(y[z].type.indexOf("image")!==-1){var x=y[z].getAsFile();var v=new FileReader();var w=this;v.onload=function(B){w.createImage(B.target.result)};v.readAsDataURL(x)}}}}else{setTimeout(this.checkInput.bind(this),100)}};e.prototype.checkInput=function(){var v=this.pasteCatcher.childNodes[0];if(v){if(v.tagName==="IMG"){this.createImage(v.src)}}this.pasteCatcher.innerHTML=""};e.prototype.createImage=function(x){var w=new Image();w.src=x;w.onload=function(){var y=x.split("base64,");var z=y[1];$("input[name=screenshot]").val(z)};var v=document.getElementById("screenshot-zone");v.innerHTML="";v.className="screenshot-pasted";v.appendChild(w);this.destroy();this.initialize()};function i(){}i.prototype.execute=function(){var v=$("#calendar");v.fullCalendar({lang:$("body").data("js-lang"),editable:true,eventLimit:true,defaultView:"month",header:{left:"prev,next today",center:"title",right:"month,agendaWeek,agendaDay"},eventDrop:function(w){$.ajax({cache:false,url:v.data("save-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({task_id:w.id,date_due:w.start.format()})})},viewRender:function(){var w=v.data("check-url");var y={start:v.fullCalendar("getView").start.format(),end:v.fullCalendar("getView").end.format()};for(var x in y){w+="&"+x+"="+y[x]}$.getJSON(w,function(z){v.fullCalendar("removeEvents");v.fullCalendar("addEventSource",z);v.fullCalendar("rerenderEvents")})}})};function j(v){this.app=v;this.checkInterval=null}j.prototype.execute=function(){this.app.swimlane.refresh();this.restoreColumnViewMode();this.compactView();this.columnScrolling();this.poll();this.keyboardShortcuts();this.listen();this.dragAndDrop();$(window).resize(this.columnScrolling)};j.prototype.poll=function(){var v=parseInt($("#board").attr("data-check-interval"));if(v>0){this.checkInterval=window.setInterval(this.check.bind(this),v*1000)}};j.prototype.reloadFilters=function(v){this.app.showLoadingIcon();$.ajax({cache:false,url:$("#board").data("reload-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({search:v}),success:this.refresh.bind(this),error:this.app.hideLoadingIcon.bind(this)})};j.prototype.check=function(){if(this.app.isVisible()){var v=this;this.app.showLoadingIcon();$.ajax({cache:false,url:$("#board").data("check-url"),statusCode:{200:function(w){v.refresh(w)},304:function(){v.app.hideLoadingIcon()}}})}};j.prototype.save=function(x,y,v,w){this.app.showLoadingIcon();$.ajax({cache:false,url:$("#board").data("save-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({task_id:x,column_id:y,swimlane_id:w,position:v}),success:this.refresh.bind(this),error:this.app.hideLoadingIcon.bind(this)})};j.prototype.refresh=function(v){$("#board-container").replaceWith(v);this.app.refresh();this.app.swimlane.refresh();this.columnScrolling();this.app.hideLoadingIcon();this.listen();this.dragAndDrop();this.compactView();this.restoreColumnViewMode()};j.prototype.dragAndDrop=function(){var v=this;var w={forcePlaceholderSize:true,tolerance:"pointer",connectWith:".board-task-list",placeholder:"draggable-placeholder",items:".draggable-item",stop:function(x,y){y.item.removeClass("draggable-item-selected");v.save(y.item.attr("data-task-id"),y.item.parent().attr("data-column-id"),y.item.index()+1,y.item.parent().attr("data-swimlane-id"))},start:function(x,y){y.item.addClass("draggable-item-selected");y.placeholder.height(y.item.height())}};if($.support.touch){$(".task-board-sort-handle").css("display","inline");w.handle=".task-board-sort-handle"}$(".board-task-list").sortable(w)};j.prototype.listen=function(){var v=this;$(document).on("click",".task-board",function(w){if(w.target.tagName!="A"){window.location=$(this).data("task-url")}});$(document).on("click",".filter-toggle-scrolling",function(w){w.preventDefault();v.toggleCompactView()});$(document).on("click",".filter-toggle-height",function(w){w.preventDefault();v.toggleColumnScrolling()});$(document).on("click",".board-column-title",function(){v.toggleColumnViewMode($(this).data("column-id"))})};j.prototype.toggleColumnScrolling=function(){var v=localStorage.getItem("column_scroll")||1;localStorage.setItem("column_scroll",v==0?1:0);this.columnScrolling()};j.prototype.columnScrolling=function(){if(localStorage.getItem("column_scroll")==0){$(".filter-max-height").show();$(".filter-min-height").hide();$(".board-task-list").each(function(){$(this).css("min-height",80);$(this).css("height","");$(".board-rotation-wrapper").css("min-height","")})}else{$(".filter-max-height").hide();$(".filter-min-height").show();if($(".board-swimlane").length>1){$(".board-task-list").each(function(){if($(this).height()>500){$(this).css("height",500)}else{$(this).css("min-height",320);$(".board-rotation-wrapper").css("min-height",320)}})}else{var v=$(window).height()-145;$(".board-task-list").css("height",v);$(".board-rotation-wrapper").css("min-height",v)}}};j.prototype.toggleCompactView=function(){var v=localStorage.getItem("horizontal_scroll")||1;localStorage.setItem("horizontal_scroll",v==0?1:0);this.compactView()};j.prototype.compactView=function(){if(localStorage.getItem("horizontal_scroll")==0){$(".filter-wide").show();$(".filter-compact").hide();$("#board-container").addClass("board-container-compact");$("#board th:not(.board-column-header-collapsed)").addClass("board-column-compact")}else{$(".filter-wide").hide();$(".filter-compact").show();$("#board-container").removeClass("board-container-compact");$("#board th").removeClass("board-column-compact")}};j.prototype.toggleCollapsedMode=function(){var v=this;this.app.showLoadingIcon();$.ajax({cache:false,url:$('.filter-display-mode:not([style="display: none;"]) a').attr("href"),success:function(w){$(".filter-display-mode").toggle();v.refresh(w)}})};j.prototype.restoreColumnViewMode=function(){var v=this;$(".board-column-header").each(function(){var w=$(this).data("column-id");if(localStorage.getItem("hidden_column_"+w)){v.hideColumn(w)}})};j.prototype.toggleColumnViewMode=function(v){if(localStorage.getItem("hidden_column_"+v)){this.showColumn(v)}else{this.hideColumn(v)}};j.prototype.hideColumn=function(v){$(".board-column-"+v+" .board-column-expanded").hide();$(".board-column-"+v+" .board-column-collapsed").show();$(".board-column-header-"+v+" .board-column-expanded").hide();$(".board-column-header-"+v+" .board-column-collapsed").show();$(".board-column-header-"+v).each(function(){$(this).removeClass("board-column-compact");$(this).addClass("board-column-header-collapsed")});$(".board-column-"+v).each(function(){$(this).addClass("board-column-task-collapsed")});$(".board-column-"+v+" .board-rotation").each(function(){$(this).css("width",$(".board-column-"+v+"").height())});localStorage.setItem("hidden_column_"+v,1)};j.prototype.showColumn=function(v){$(".board-column-"+v+" .board-column-expanded").show();$(".board-column-"+v+" .board-column-collapsed").hide();$(".board-column-header-"+v+" .board-column-expanded").show();$(".board-column-header-"+v+" .board-column-collapsed").hide();$(".board-column-header-"+v).removeClass("board-column-header-collapsed");$(".board-column-"+v).removeClass("board-column-task-collapsed");if(localStorage.getItem("horizontal_scroll")==0){$(".board-column-header-"+v).addClass("board-column-compact")}localStorage.removeItem("hidden_column_"+v)};j.prototype.keyboardShortcuts=function(){var v=this;Mousetrap.bind("c",function(){v.toggleCompactView()});Mousetrap.bind("s",function(){v.toggleCollapsedMode()});Mousetrap.bind("n",function(){v.app.popover.open($("#board").data("task-creation-url"))})};function g(){}g.prototype.getStorageKey=function(){return"hidden_swimlanes_"+$("#board").data("project-id")};g.prototype.expand=function(w){var x=this.getAllCollapsed();var v=x.indexOf(w);if(v>-1){x.splice(v,1)}localStorage.setItem(this.getStorageKey(),JSON.stringify(x));$(".board-swimlane-columns-"+w).css("display","table-row");$(".board-swimlane-tasks-"+w).css("display","table-row");$(".hide-icon-swimlane-"+w).css("display","inline");$(".show-icon-swimlane-"+w).css("display","none")};g.prototype.collapse=function(v){var w=this.getAllCollapsed();if(w.indexOf(v)<0){w.push(v);localStorage.setItem(this.getStorageKey(),JSON.stringify(w))}$(".board-swimlane-columns-"+v+":not(:first-child)").css("display","none");$(".board-swimlane-tasks-"+v).css("display","none");$(".hide-icon-swimlane-"+v).css("display","none");$(".show-icon-swimlane-"+v).css("display","inline")};g.prototype.isCollapsed=function(v){return this.getAllCollapsed().indexOf(v)>-1};g.prototype.getAllCollapsed=function(){return JSON.parse(localStorage.getItem(this.getStorageKey()))||[]};g.prototype.refresh=function(){var w=this.getAllCollapsed();for(var v=0;v<w.length;v++){this.collapse(w[v])}};g.prototype.listen=function(){var v=this;$(document).on("click",".board-swimlane-toggle",function(x){x.preventDefault();var w=$(this).data("swimlane-id");if(v.isCollapsed(w)){v.expand(w)}else{v.collapse(w)}})};function c(v){this.app=v;this.data=[];this.options={container:"#gantt-chart",showWeekends:true,allowMoves:true,allowResizes:true,cellWidth:21,cellHeight:31,slideWidth:1000,vHeaderWidth:200}}c.prototype.saveRecord=function(v){this.app.showLoadingIcon();$.ajax({cache:false,url:$(this.options.container).data("save-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify(v),complete:this.app.hideLoadingIcon.bind(this)})};c.prototype.execute=function(){this.data=this.prepareData($(this.options.container).data("records"));var y=Math.floor((this.options.slideWidth/this.options.cellWidth)+5);var x=this.getDateRange(y);var v=x[0];var A=x[1];var w=$(this.options.container);var z=jQuery("<div>",{"class":"ganttview"});z.append(this.renderVerticalHeader());z.append(this.renderSlider(v,A));w.append(z);jQuery("div.ganttview-grid-row div.ganttview-grid-row-cell:last-child",w).addClass("last");jQuery("div.ganttview-hzheader-days div.ganttview-hzheader-day:last-child",w).addClass("last");jQuery("div.ganttview-hzheader-months div.ganttview-hzheader-month:last-child",w).addClass("last");if(!$(this.options.container).data("readonly")){this.listenForBlockResize(v);this.listenForBlockMove(v)}else{this.options.allowResizes=false;this.options.allowMoves=false}};c.prototype.renderVerticalHeader=function(){var z=jQuery("<div>",{"class":"ganttview-vtheader"});var w=jQuery("<div>",{"class":"ganttview-vtheader-item"});var y=jQuery("<div>",{"class":"ganttview-vtheader-series"});for(var v=0;v<this.data.length;v++){var x=jQuery("<span>").append(jQuery("<i>",{"class":"fa fa-info-circle tooltip",title:this.getVerticalHeaderTooltip(this.data[v])})).append("&nbsp;");if(this.data[v].type=="task"){x.append(jQuery("<a>",{href:this.data[v].link,target:"_blank",title:this.data[v].title}).append(this.data[v].title))}else{x.append(jQuery("<a>",{href:this.data[v].board_link,target:"_blank",title:$(this.options.container).data("label-board-link")}).append('<i class="fa fa-th"></i>')).append("&nbsp;").append(jQuery("<a>",{href:this.data[v].gantt_link,target:"_blank",title:$(this.options.container).data("label-gantt-link")}).append('<i class="fa fa-sliders"></i>')).append("&nbsp;").append(jQuery("<a>",{href:this.data[v].link,target:"_blank"}).append(this.data[v].title))}y.append(jQuery("<div>",{"class":"ganttview-vtheader-series-name"}).append(x))}w.append(y);z.append(w);return z};c.prototype.renderSlider=function(w,y){var v=jQuery("<div>",{"class":"ganttview-slide-container"});var x=this.getDates(w,y);v.append(this.renderHorizontalHeader(x));v.append(this.renderGrid(x));v.append(this.addBlockContainers());this.addBlocks(v,w);return v};c.prototype.renderHorizontalHeader=function(v){var D=jQuery("<div>",{"class":"ganttview-hzheader"});var B=jQuery("<div>",{"class":"ganttview-hzheader-months"});var A=jQuery("<div>",{"class":"ganttview-hzheader-days"});var z=0;for(var E in v){for(var x in v[E]){var F=v[E][x].length*this.options.cellWidth;z=z+F;B.append(jQuery("<div>",{"class":"ganttview-hzheader-month",css:{width:(F-1)+"px"}}).append($.datepicker.regional[$("body").data("js-lang")].monthNames[x]+" "+E));for(var C in v[E][x]){A.append(jQuery("<div>",{"class":"ganttview-hzheader-day"}).append(v[E][x][C].getDate()))}}}B.css("width",z+"px");A.css("width",z+"px");D.append(B).append(A);return D};c.prototype.renderGrid=function(v){var F=jQuery("<div>",{"class":"ganttview-grid"});var A=jQuery("<div>",{"class":"ganttview-grid-row"});for(var D in v){for(var x in v[D]){for(var C in v[D][x]){var z=jQuery("<div>",{"class":"ganttview-grid-row-cell"});if(this.options.showWeekends&&this.isWeekend(v[D][x][C])){z.addClass("ganttview-weekend")}A.append(z)}}}var E=jQuery("div.ganttview-grid-row-cell",A).length*this.options.cellWidth;A.css("width",E+"px");F.css("width",E+"px");for(var B=0;B<this.data.length;B++){F.append(A.clone())}return F};c.prototype.addBlockContainers=function(){var w=jQuery("<div>",{"class":"ganttview-blocks"});for(var v=0;v<this.data.length;v++){w.append(jQuery("<div>",{"class":"ganttview-block-container"}))}return w};c.prototype.addBlocks=function(w,v){var D=jQuery("div.ganttview-blocks div.ganttview-block-container",w);var x=0;for(var A=0;A<this.data.length;A++){var B=this.data[A];var E=this.daysBetween(B.start,B.end)+1;var z=this.daysBetween(v,B.start);var C=jQuery("<div>",{"class":"ganttview-block-text"});var y=jQuery("<div>",{"class":"ganttview-block tooltip"+(this.options.allowMoves?" ganttview-block-movable":""),title:this.getBarTooltip(this.data[A]),css:{width:((E*this.options.cellWidth)-9)+"px","margin-left":(z*this.options.cellWidth)+"px"}}).append(C);if(E>=2){C.append(this.data[A].progress)}y.data("record",this.data[A]);this.setBarColor(y,this.data[A]);y.append(jQuery("<div>",{css:{"z-index":0,position:"absolute",top:0,bottom:0,"background-color":B.color.border,width:B.progress,opacity:0.4}}));jQuery(D[x]).append(y);x=x+1}};c.prototype.getVerticalHeaderTooltip=function(w){var B="";if(w.type=="task"){B="<strong>"+w.column_title+"</strong> ("+w.progress+")<br/>"+w.title}else{var y=["managers","members"];for(var x in y){var z=y[x];if(!jQuery.isEmptyObject(w.users[z])){var A=jQuery("<ul>");for(var v in w.users[z]){A.append(jQuery("<li>").append(w.users[z][v]))}B+="<p><strong>"+$(this.options.container).data("label-"+z)+"</strong></p>"+A[0].outerHTML}}}return B};c.prototype.getBarTooltip=function(v){var w="";if(v.not_defined){w=$(this.options.container).data("label-not-defined")}else{if(v.type=="task"){w="<strong>"+v.progress+"</strong><br/>"+$(this.options.container).data("label-assignee")+" "+(v.assignee?v.assignee:"")+"<br/>"}w+=$(this.options.container).data("label-start-date")+" "+$.datepicker.formatDate("yy-mm-dd",v.start)+"<br/>";w+=$(this.options.container).data("label-end-date")+" "+$.datepicker.formatDate("yy-mm-dd",v.end)}return w};c.prototype.setBarColor=function(w,v){if(v.not_defined){w.addClass("ganttview-block-not-defined")}else{w.css("background-color",v.color.background);w.css("border-color",v.color.border)}};c.prototype.listenForBlockResize=function(v){var w=this;jQuery("div.ganttview-block",this.options.container).resizable({grid:this.options.cellWidth,handles:"e,w",delay:300,stop:function(){var x=jQuery(this);w.updateDataAndPosition(x,v);w.saveRecord(x.data("record"))}})};c.prototype.listenForBlockMove=function(v){var w=this;jQuery("div.ganttview-block",this.options.container).draggable({axis:"x",delay:300,grid:[this.options.cellWidth,this.options.cellWidth],stop:function(){var x=jQuery(this);w.updateDataAndPosition(x,v);w.saveRecord(x.data("record"))}})};c.prototype.updateDataAndPosition=function(A,y){var v=jQuery("div.ganttview-slide-container",this.options.container);var E=v.scrollLeft();var B=A.offset().left-v.offset().left-1+E;var D=A.data("record");D.not_defined=false;this.setBarColor(A,D);var x=Math.round(B/this.options.cellWidth);var C=this.addDays(this.cloneDate(y),x);D.start=C;var w=A.outerWidth();var z=Math.round(w/this.options.cellWidth)-1;D.end=this.addDays(this.cloneDate(C),z);if(D.type==="task"&&z>0){jQuery("div.ganttview-block-text",A).text(D.progress)}A.attr("title",this.getBarTooltip(D));A.data("record",D);A.css("top","").css("left","").css("position","relative").css("margin-left",B+"px")};c.prototype.getDates=function(z,v){var y=[];y[z.getFullYear()]=[];y[z.getFullYear()][z.getMonth()]=[z];var x=z;while(this.compareDate(x,v)==-1){var w=this.addDays(this.cloneDate(x),1);if(!y[w.getFullYear()]){y[w.getFullYear()]=[]}if(!y[w.getFullYear()][w.getMonth()]){y[w.getFullYear()][w.getMonth()]=[]}y[w.getFullYear()][w.getMonth()].push(w);x=w}return y};c.prototype.prepareData=function(x){for(var w=0;w<x.length;w++){var y=new Date(x[w].start[0],x[w].start[1]-1,x[w].start[2],0,0,0,0);x[w].start=y;var v=new Date(x[w].end[0],x[w].end[1]-1,x[w].end[2],0,0,0,0);x[w].end=v}return x};c.prototype.getDateRange=function(x){var A=new Date();var w=new Date();for(var y=0;y<this.data.length;y++){var z=new Date();z.setTime(Date.parse(this.data[y].start));var v=new Date();v.setTime(Date.parse(this.data[y].end));if(y==0){A=z;w=v}if(this.compareDate(A,z)==1){A=z}if(this.compareDate(w,v)==-1){w=v}}if(this.daysBetween(A,w)<x){w=this.addDays(this.cloneDate(A),x)}A.setDate(A.getDate()-1);return[A,w]};c.prototype.daysBetween=function(y,v){if(!y||!v){return 0}var x=0,w=this.cloneDate(y);while(this.compareDate(w,v)==-1){x=x+1;this.addDays(w,1)}return x};c.prototype.isWeekend=function(v){return v.getDay()%6==0};c.prototype.cloneDate=function(v){return new Date(v.getTime())};c.prototype.addDays=function(v,w){v.setDate(v.getDate()+w*1);return v};c.prototype.compareDate=function(w,v){if(isNaN(w)||isNaN(v)){throw new Error(w+" - "+v)}else{if(w instanceof Date&&v instanceof Date){return(w<v)?-1:(w>v)?1:0}else{throw new TypeError(w+" - "+v)}}};function a(){}a.prototype.listen=function(){$(document).on("click",".color-square",function(){$(".color-square-selected").removeClass("color-square-selected");$(this).addClass("color-square-selected");$("#form-color_id").val($(this).data("color-id"))})};function q(){}q.prototype.execute=function(){var x=$("#chart").data("metrics");var w=[];for(var v=0;v<x.length;v++){w.push([x[v].column_title,x[v].nb_tasks])}c3.generate({data:{columns:w,type:"donut"}})};function n(){}n.prototype.execute=function(){var x=$("#chart").data("metrics");var w=[];for(var v=0;v<x.length;v++){w.push([x[v].user,x[v].nb_tasks])}c3.generate({data:{columns:w,type:"donut"}})};function d(){}d.prototype.execute=function(){var B=$("#chart").data("metrics");var A=[];var v=[];var w=[];var y=d3.time.format("%Y-%m-%d");var C=d3.time.format($("#chart").data("date-format"));for(var z=0;z<B.length;z++){for(var x=0;x<B[z].length;x++){if(z==0){A.push([B[z][x]]);if(x>0){v.push(B[z][x])}}else{A[x].push(B[z][x]);if(x==0){w.push(C(y.parse(B[z][x])))}}}}c3.generate({data:{columns:A,type:"area-spline",groups:[v]},axis:{x:{type:"category",categories:w}}})};function m(){}m.prototype.execute=function(){var A=$("#chart").data("metrics");var z=[[$("#chart").data("label-total")]];var v=[];var x=d3.time.format("%Y-%m-%d");var B=d3.time.format($("#chart").data("date-format"));for(var y=0;y<A.length;y++){for(var w=0;w<A[y].length;w++){if(y==0){z.push([A[y][w]])}else{z[w+1].push(A[y][w]);if(w>0){if(z[0][y]==undefined){z[0].push(0)}z[0][y]+=A[y][w]}if(w==0){v.push(B(x.parse(A[y][w])))}}}}c3.generate({data:{columns:z},axis:{x:{type:"category",categories:v}}})};function h(v){this.app=v}h.prototype.execute=function(){var x=$("#chart").data("metrics");var y=[$("#chart").data("label")];var v=[];for(var w in x){y.push(x[w].average);v.push(x[w].title)}c3.generate({data:{columns:[y],type:"bar"},bar:{width:{ratio:0.5}},axis:{x:{type:"category",categories:v},y:{tick:{format:this.app.formatDuration}}},legend:{show:false}})};function u(v){this.app=v}u.prototype.execute=function(){var x=$("#chart").data("metrics");var y=[$("#chart").data("label")];var v=[];for(var w=0;w<x.length;w++){y.push(x[w].time_spent);v.push(x[w].title)}c3.generate({data:{columns:[y],type:"bar"},bar:{width:{ratio:0.5}},axis:{x:{type:"category",categories:v},y:{tick:{format:this.app.formatDuration}}},legend:{show:false}})};function s(v){this.app=v}s.prototype.execute=function(){var B=$("#chart").data("metrics");var A=[$("#chart").data("label-cycle")];var x=[$("#chart").data("label-lead")];var w=[];var z={};z[$("#chart").data("label-cycle")]="area";z[$("#chart").data("label-lead")]="area-spline";var v={};v[$("#chart").data("label-lead")]="#afb42b";v[$("#chart").data("label-cycle")]="#4e342e";for(var y=0;y<B.length;y++){A.push(parseInt(B[y].avg_cycle_time));x.push(parseInt(B[y].avg_lead_time));w.push(B[y].day)}c3.generate({data:{columns:[x,A],types:z,colors:v},axis:{x:{type:"category",categories:w},y:{tick:{format:this.app.formatDuration}}}})};function t(){this.routes={}}t.prototype.addRoute=function(w,v){this.routes[w]=v};t.prototype.dispatch=function(w){for(var x in this.routes){if(document.getElementById(x)){var v=Object.create(this.routes[x].prototype);this.routes[x].apply(v,[w]);v.execute();break}}};jQuery(document).ready(function(){var w=new l();var v=new t();v.addRoute("board",j);v.addRoute("calendar",i);v.addRoute("screenshot-zone",e);v.addRoute("analytic-task-repartition",q);v.addRoute("analytic-user-repartition",n);v.addRoute("analytic-cfd",d);v.addRoute("analytic-burndown",m);v.addRoute("analytic-avg-time-column",h);v.addRoute("analytic-task-time-column",u);v.addRoute("analytic-lead-cycle-time",s);v.addRoute("gantt-chart",c);v.dispatch(w);w.listen()})})(); \ No newline at end of file
+(function (factory) {
+ if (typeof define === 'function' && define.amd) {
+ // AMD. Register as an anonymous module.
+ define(['jquery'], factory);
+ } else if (typeof module === "object" && module.exports) {
+ var $ = require('jquery');
+ module.exports = factory($);
+ } else {
+ // Browser globals
+ factory(jQuery);
+ }
+}(function (jQuery) {
+
+/*!
+ * jQuery.textcomplete
+ *
+ * Repository: https://github.com/yuku-t/jquery-textcomplete
+ * License: MIT (https://github.com/yuku-t/jquery-textcomplete/blob/master/LICENSE)
+ * Author: Yuku Takahashi
+ */
+
+if (typeof jQuery === 'undefined') {
+ throw new Error('jQuery.textcomplete requires jQuery');
+}
+
++function ($) {
+ 'use strict';
+
+ var warn = function (message) {
+ if (console.warn) { console.warn(message); }
+ };
+
+ var id = 1;
+
+ $.fn.textcomplete = function (strategies, option) {
+ var args = Array.prototype.slice.call(arguments);
+ return this.each(function () {
+ var self = this;
+ var $this = $(this);
+ var completer = $this.data('textComplete');
+ if (!completer) {
+ option || (option = {});
+ option._oid = id++; // unique object id
+ completer = new $.fn.textcomplete.Completer(this, option);
+ $this.data('textComplete', completer);
+ }
+ if (typeof strategies === 'string') {
+ if (!completer) return;
+ args.shift()
+ completer[strategies].apply(completer, args);
+ if (strategies === 'destroy') {
+ $this.removeData('textComplete');
+ }
+ } else {
+ // For backward compatibility.
+ // TODO: Remove at v0.4
+ $.each(strategies, function (obj) {
+ $.each(['header', 'footer', 'placement', 'maxCount'], function (name) {
+ if (obj[name]) {
+ completer.option[name] = obj[name];
+ warn(name + 'as a strategy param is deprecated. Use option.');
+ delete obj[name];
+ }
+ });
+ });
+ completer.register($.fn.textcomplete.Strategy.parse(strategies, {
+ el: self,
+ $el: $this
+ }));
+ }
+ });
+ };
+
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ // Exclusive execution control utility.
+ //
+ // func - The function to be locked. It is executed with a function named
+ // `free` as the first argument. Once it is called, additional
+ // execution are ignored until the free is invoked. Then the last
+ // ignored execution will be replayed immediately.
+ //
+ // Examples
+ //
+ // var lockedFunc = lock(function (free) {
+ // setTimeout(function { free(); }, 1000); // It will be free in 1 sec.
+ // console.log('Hello, world');
+ // });
+ // lockedFunc(); // => 'Hello, world'
+ // lockedFunc(); // none
+ // lockedFunc(); // none
+ // // 1 sec past then
+ // // => 'Hello, world'
+ // lockedFunc(); // => 'Hello, world'
+ // lockedFunc(); // none
+ //
+ // Returns a wrapped function.
+ var lock = function (func) {
+ var locked, queuedArgsToReplay;
+
+ return function () {
+ // Convert arguments into a real array.
+ var args = Array.prototype.slice.call(arguments);
+ if (locked) {
+ // Keep a copy of this argument list to replay later.
+ // OK to overwrite a previous value because we only replay
+ // the last one.
+ queuedArgsToReplay = args;
+ return;
+ }
+ locked = true;
+ var self = this;
+ args.unshift(function replayOrFree() {
+ if (queuedArgsToReplay) {
+ // Other request(s) arrived while we were locked.
+ // Now that the lock is becoming available, replay
+ // the latest such request, then call back here to
+ // unlock (or replay another request that arrived
+ // while this one was in flight).
+ var replayArgs = queuedArgsToReplay;
+ queuedArgsToReplay = undefined;
+ replayArgs.unshift(replayOrFree);
+ func.apply(self, replayArgs);
+ } else {
+ locked = false;
+ }
+ });
+ func.apply(this, args);
+ };
+ };
+
+ var isString = function (obj) {
+ return Object.prototype.toString.call(obj) === '[object String]';
+ };
+
+ var isFunction = function (obj) {
+ return Object.prototype.toString.call(obj) === '[object Function]';
+ };
+
+ var uniqueId = 0;
+
+ function Completer(element, option) {
+ this.$el = $(element);
+ this.id = 'textcomplete' + uniqueId++;
+ this.strategies = [];
+ this.views = [];
+ this.option = $.extend({}, Completer._getDefaults(), option);
+
+ if (!this.$el.is('input[type=text]') && !this.$el.is('textarea') && !element.isContentEditable && element.contentEditable != 'true') {
+ throw new Error('textcomplete must be called on a Textarea or a ContentEditable.');
+ }
+
+ if (element === document.activeElement) {
+ // element has already been focused. Initialize view objects immediately.
+ this.initialize()
+ } else {
+ // Initialize view objects lazily.
+ var self = this;
+ this.$el.one('focus.' + this.id, function () { self.initialize(); });
+ }
+ }
+
+ Completer._getDefaults = function () {
+ if (!Completer.DEFAULTS) {
+ Completer.DEFAULTS = {
+ appendTo: $('body'),
+ zIndex: '100'
+ };
+ }
+
+ return Completer.DEFAULTS;
+ }
+
+ $.extend(Completer.prototype, {
+ // Public properties
+ // -----------------
+
+ id: null,
+ option: null,
+ strategies: null,
+ adapter: null,
+ dropdown: null,
+ $el: null,
+
+ // Public methods
+ // --------------
+
+ initialize: function () {
+ var element = this.$el.get(0);
+ // Initialize view objects.
+ this.dropdown = new $.fn.textcomplete.Dropdown(element, this, this.option);
+ var Adapter, viewName;
+ if (this.option.adapter) {
+ Adapter = this.option.adapter;
+ } else {
+ if (this.$el.is('textarea') || this.$el.is('input[type=text]')) {
+ viewName = typeof element.selectionEnd === 'number' ? 'Textarea' : 'IETextarea';
+ } else {
+ viewName = 'ContentEditable';
+ }
+ Adapter = $.fn.textcomplete[viewName];
+ }
+ this.adapter = new Adapter(element, this, this.option);
+ },
+
+ destroy: function () {
+ this.$el.off('.' + this.id);
+ if (this.adapter) {
+ this.adapter.destroy();
+ }
+ if (this.dropdown) {
+ this.dropdown.destroy();
+ }
+ this.$el = this.adapter = this.dropdown = null;
+ },
+
+ // Invoke textcomplete.
+ trigger: function (text, skipUnchangedTerm) {
+ if (!this.dropdown) { this.initialize(); }
+ text != null || (text = this.adapter.getTextFromHeadToCaret());
+ var searchQuery = this._extractSearchQuery(text);
+ if (searchQuery.length) {
+ var term = searchQuery[1];
+ // Ignore shift-key, ctrl-key and so on.
+ if (skipUnchangedTerm && this._term === term) { return; }
+ this._term = term;
+ this._search.apply(this, searchQuery);
+ } else {
+ this._term = null;
+ this.dropdown.deactivate();
+ }
+ },
+
+ fire: function (eventName) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ this.$el.trigger(eventName, args);
+ return this;
+ },
+
+ register: function (strategies) {
+ Array.prototype.push.apply(this.strategies, strategies);
+ },
+
+ // Insert the value into adapter view. It is called when the dropdown is clicked
+ // or selected.
+ //
+ // value - The selected element of the array callbacked from search func.
+ // strategy - The Strategy object.
+ // e - Click or keydown event object.
+ select: function (value, strategy, e) {
+ this._term = null;
+ this.adapter.select(value, strategy, e);
+ this.fire('change').fire('textComplete:select', value, strategy);
+ this.adapter.focus();
+ },
+
+ // Private properties
+ // ------------------
+
+ _clearAtNext: true,
+ _term: null,
+
+ // Private methods
+ // ---------------
+
+ // Parse the given text and extract the first matching strategy.
+ //
+ // Returns an array including the strategy, the query term and the match
+ // object if the text matches an strategy; otherwise returns an empty array.
+ _extractSearchQuery: function (text) {
+ for (var i = 0; i < this.strategies.length; i++) {
+ var strategy = this.strategies[i];
+ var context = strategy.context(text);
+ if (context || context === '') {
+ var matchRegexp = isFunction(strategy.match) ? strategy.match(text) : strategy.match;
+ if (isString(context)) { text = context; }
+ var match = text.match(matchRegexp);
+ if (match) { return [strategy, match[strategy.index], match]; }
+ }
+ }
+ return []
+ },
+
+ // Call the search method of selected strategy..
+ _search: lock(function (free, strategy, term, match) {
+ var self = this;
+ strategy.search(term, function (data, stillSearching) {
+ if (!self.dropdown.shown) {
+ self.dropdown.activate();
+ }
+ if (self._clearAtNext) {
+ // The first callback in the current lock.
+ self.dropdown.clear();
+ self._clearAtNext = false;
+ }
+ self.dropdown.setPosition(self.adapter.getCaretPosition());
+ self.dropdown.render(self._zip(data, strategy, term));
+ if (!stillSearching) {
+ // The last callback in the current lock.
+ free();
+ self._clearAtNext = true; // Call dropdown.clear at the next time.
+ }
+ }, match);
+ }),
+
+ // Build a parameter for Dropdown#render.
+ //
+ // Examples
+ //
+ // this._zip(['a', 'b'], 's');
+ // //=> [{ value: 'a', strategy: 's' }, { value: 'b', strategy: 's' }]
+ _zip: function (data, strategy, term) {
+ return $.map(data, function (value) {
+ return { value: value, strategy: strategy, term: term };
+ });
+ }
+ });
+
+ $.fn.textcomplete.Completer = Completer;
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ var $window = $(window);
+
+ var include = function (zippedData, datum) {
+ var i, elem;
+ var idProperty = datum.strategy.idProperty
+ for (i = 0; i < zippedData.length; i++) {
+ elem = zippedData[i];
+ if (elem.strategy !== datum.strategy) continue;
+ if (idProperty) {
+ if (elem.value[idProperty] === datum.value[idProperty]) return true;
+ } else {
+ if (elem.value === datum.value) return true;
+ }
+ }
+ return false;
+ };
+
+ var dropdownViews = {};
+ $(document).on('click', function (e) {
+ var id = e.originalEvent && e.originalEvent.keepTextCompleteDropdown;
+ $.each(dropdownViews, function (key, view) {
+ if (key !== id) { view.deactivate(); }
+ });
+ });
+
+ var commands = {
+ SKIP_DEFAULT: 0,
+ KEY_UP: 1,
+ KEY_DOWN: 2,
+ KEY_ENTER: 3,
+ KEY_PAGEUP: 4,
+ KEY_PAGEDOWN: 5,
+ KEY_ESCAPE: 6
+ };
+
+ // Dropdown view
+ // =============
+
+ // Construct Dropdown object.
+ //
+ // element - Textarea or contenteditable element.
+ function Dropdown(element, completer, option) {
+ this.$el = Dropdown.createElement(option);
+ this.completer = completer;
+ this.id = completer.id + 'dropdown';
+ this._data = []; // zipped data.
+ this.$inputEl = $(element);
+ this.option = option;
+
+ // Override setPosition method.
+ if (option.listPosition) { this.setPosition = option.listPosition; }
+ if (option.height) { this.$el.height(option.height); }
+ var self = this;
+ $.each(['maxCount', 'placement', 'footer', 'header', 'noResultsMessage', 'className'], function (_i, name) {
+ if (option[name] != null) { self[name] = option[name]; }
+ });
+ this._bindEvents(element);
+ dropdownViews[this.id] = this;
+ }
+
+ $.extend(Dropdown, {
+ // Class methods
+ // -------------
+
+ createElement: function (option) {
+ var $parent = option.appendTo;
+ if (!($parent instanceof $)) { $parent = $($parent); }
+ var $el = $('<ul></ul>')
+ .addClass('textcomplete-dropdown')
+ .attr('id', 'textcomplete-dropdown-' + option._oid)
+ .css({
+ display: 'none',
+ left: 0,
+ position: 'absolute',
+ zIndex: option.zIndex
+ })
+ .appendTo($parent);
+ return $el;
+ }
+ });
+
+ $.extend(Dropdown.prototype, {
+ // Public properties
+ // -----------------
+
+ $el: null, // jQuery object of ul.dropdown-menu element.
+ $inputEl: null, // jQuery object of target textarea.
+ completer: null,
+ footer: null,
+ header: null,
+ id: null,
+ maxCount: 10,
+ placement: '',
+ shown: false,
+ data: [], // Shown zipped data.
+ className: '',
+
+ // Public methods
+ // --------------
+
+ destroy: function () {
+ // Don't remove $el because it may be shared by several textcompletes.
+ this.deactivate();
+
+ this.$el.off('.' + this.id);
+ this.$inputEl.off('.' + this.id);
+ this.clear();
+ this.$el = this.$inputEl = this.completer = null;
+ delete dropdownViews[this.id]
+ },
+
+ render: function (zippedData) {
+ var contentsHtml = this._buildContents(zippedData);
+ var unzippedData = $.map(this.data, function (d) { return d.value; });
+ if (this.data.length) {
+ this._renderHeader(unzippedData);
+ this._renderFooter(unzippedData);
+ if (contentsHtml) {
+ this._renderContents(contentsHtml);
+ this._fitToBottom();
+ this._activateIndexedItem();
+ }
+ this._setScroll();
+ } else if (this.noResultsMessage) {
+ this._renderNoResultsMessage(unzippedData);
+ } else if (this.shown) {
+ this.deactivate();
+ }
+ },
+
+ setPosition: function (pos) {
+ this.$el.css(this._applyPlacement(pos));
+
+ // Make the dropdown fixed if the input is also fixed
+ // This can't be done during init, as textcomplete may be used on multiple elements on the same page
+ // Because the same dropdown is reused behind the scenes, we need to recheck every time the dropdown is showed
+ var position = 'absolute';
+ // Check if input or one of its parents has positioning we need to care about
+ this.$inputEl.add(this.$inputEl.parents()).each(function() {
+ if($(this).css('position') === 'absolute') // The element has absolute positioning, so it's all OK
+ return false;
+ if($(this).css('position') === 'fixed') {
+ position = 'fixed';
+ return false;
+ }
+ });
+ this.$el.css({ position: position }); // Update positioning
+
+ return this;
+ },
+
+ clear: function () {
+ this.$el.html('');
+ this.data = [];
+ this._index = 0;
+ this._$header = this._$footer = this._$noResultsMessage = null;
+ },
+
+ activate: function () {
+ if (!this.shown) {
+ this.clear();
+ this.$el.show();
+ if (this.className) { this.$el.addClass(this.className); }
+ this.completer.fire('textComplete:show');
+ this.shown = true;
+ }
+ return this;
+ },
+
+ deactivate: function () {
+ if (this.shown) {
+ this.$el.hide();
+ if (this.className) { this.$el.removeClass(this.className); }
+ this.completer.fire('textComplete:hide');
+ this.shown = false;
+ }
+ return this;
+ },
+
+ isUp: function (e) {
+ return e.keyCode === 38 || (e.ctrlKey && e.keyCode === 80); // UP, Ctrl-P
+ },
+
+ isDown: function (e) {
+ return e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78); // DOWN, Ctrl-N
+ },
+
+ isEnter: function (e) {
+ var modifiers = e.ctrlKey || e.altKey || e.metaKey || e.shiftKey;
+ return !modifiers && (e.keyCode === 13 || e.keyCode === 9 || (this.option.completeOnSpace === true && e.keyCode === 32)) // ENTER, TAB
+ },
+
+ isPageup: function (e) {
+ return e.keyCode === 33; // PAGEUP
+ },
+
+ isPagedown: function (e) {
+ return e.keyCode === 34; // PAGEDOWN
+ },
+
+ isEscape: function (e) {
+ return e.keyCode === 27; // ESCAPE
+ },
+
+ // Private properties
+ // ------------------
+
+ _data: null, // Currently shown zipped data.
+ _index: null,
+ _$header: null,
+ _$noResultsMessage: null,
+ _$footer: null,
+
+ // Private methods
+ // ---------------
+
+ _bindEvents: function () {
+ this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this));
+ this.$el.on('touchstart.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this));
+ this.$el.on('mouseover.' + this.id, '.textcomplete-item', $.proxy(this._onMouseover, this));
+ this.$inputEl.on('keydown.' + this.id, $.proxy(this._onKeydown, this));
+ },
+
+ _onClick: function (e) {
+ var $el = $(e.target);
+ e.preventDefault();
+ e.originalEvent.keepTextCompleteDropdown = this.id;
+ if (!$el.hasClass('textcomplete-item')) {
+ $el = $el.closest('.textcomplete-item');
+ }
+ var datum = this.data[parseInt($el.data('index'), 10)];
+ this.completer.select(datum.value, datum.strategy, e);
+ var self = this;
+ // Deactive at next tick to allow other event handlers to know whether
+ // the dropdown has been shown or not.
+ setTimeout(function () {
+ self.deactivate();
+ if (e.type === 'touchstart') {
+ self.$inputEl.focus();
+ }
+ }, 0);
+ },
+
+ // Activate hovered item.
+ _onMouseover: function (e) {
+ var $el = $(e.target);
+ e.preventDefault();
+ if (!$el.hasClass('textcomplete-item')) {
+ $el = $el.closest('.textcomplete-item');
+ }
+ this._index = parseInt($el.data('index'), 10);
+ this._activateIndexedItem();
+ },
+
+ _onKeydown: function (e) {
+ if (!this.shown) { return; }
+
+ var command;
+
+ if ($.isFunction(this.option.onKeydown)) {
+ command = this.option.onKeydown(e, commands);
+ }
+
+ if (command == null) {
+ command = this._defaultKeydown(e);
+ }
+
+ switch (command) {
+ case commands.KEY_UP:
+ e.preventDefault();
+ this._up();
+ break;
+ case commands.KEY_DOWN:
+ e.preventDefault();
+ this._down();
+ break;
+ case commands.KEY_ENTER:
+ e.preventDefault();
+ this._enter(e);
+ break;
+ case commands.KEY_PAGEUP:
+ e.preventDefault();
+ this._pageup();
+ break;
+ case commands.KEY_PAGEDOWN:
+ e.preventDefault();
+ this._pagedown();
+ break;
+ case commands.KEY_ESCAPE:
+ e.preventDefault();
+ this.deactivate();
+ break;
+ }
+ },
+
+ _defaultKeydown: function (e) {
+ if (this.isUp(e)) {
+ return commands.KEY_UP;
+ } else if (this.isDown(e)) {
+ return commands.KEY_DOWN;
+ } else if (this.isEnter(e)) {
+ return commands.KEY_ENTER;
+ } else if (this.isPageup(e)) {
+ return commands.KEY_PAGEUP;
+ } else if (this.isPagedown(e)) {
+ return commands.KEY_PAGEDOWN;
+ } else if (this.isEscape(e)) {
+ return commands.KEY_ESCAPE;
+ }
+ },
+
+ _up: function () {
+ if (this._index === 0) {
+ this._index = this.data.length - 1;
+ } else {
+ this._index -= 1;
+ }
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _down: function () {
+ if (this._index === this.data.length - 1) {
+ this._index = 0;
+ } else {
+ this._index += 1;
+ }
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _enter: function (e) {
+ var datum = this.data[parseInt(this._getActiveElement().data('index'), 10)];
+ this.completer.select(datum.value, datum.strategy, e);
+ this.deactivate();
+ },
+
+ _pageup: function () {
+ var target = 0;
+ var threshold = this._getActiveElement().position().top - this.$el.innerHeight();
+ this.$el.children().each(function (i) {
+ if ($(this).position().top + $(this).outerHeight() > threshold) {
+ target = i;
+ return false;
+ }
+ });
+ this._index = target;
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _pagedown: function () {
+ var target = this.data.length - 1;
+ var threshold = this._getActiveElement().position().top + this.$el.innerHeight();
+ this.$el.children().each(function (i) {
+ if ($(this).position().top > threshold) {
+ target = i;
+ return false
+ }
+ });
+ this._index = target;
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _activateIndexedItem: function () {
+ this.$el.find('.textcomplete-item.active').removeClass('active');
+ this._getActiveElement().addClass('active');
+ },
+
+ _getActiveElement: function () {
+ return this.$el.children('.textcomplete-item:nth(' + this._index + ')');
+ },
+
+ _setScroll: function () {
+ var $activeEl = this._getActiveElement();
+ var itemTop = $activeEl.position().top;
+ var itemHeight = $activeEl.outerHeight();
+ var visibleHeight = this.$el.innerHeight();
+ var visibleTop = this.$el.scrollTop();
+ if (this._index === 0 || this._index == this.data.length - 1 || itemTop < 0) {
+ this.$el.scrollTop(itemTop + visibleTop);
+ } else if (itemTop + itemHeight > visibleHeight) {
+ this.$el.scrollTop(itemTop + itemHeight + visibleTop - visibleHeight);
+ }
+ },
+
+ _buildContents: function (zippedData) {
+ var datum, i, index;
+ var html = '';
+ for (i = 0; i < zippedData.length; i++) {
+ if (this.data.length === this.maxCount) break;
+ datum = zippedData[i];
+ if (include(this.data, datum)) { continue; }
+ index = this.data.length;
+ this.data.push(datum);
+ html += '<li class="textcomplete-item" data-index="' + index + '"><a>';
+ html += datum.strategy.template(datum.value, datum.term);
+ html += '</a></li>';
+ }
+ return html;
+ },
+
+ _renderHeader: function (unzippedData) {
+ if (this.header) {
+ if (!this._$header) {
+ this._$header = $('<li class="textcomplete-header"></li>').prependTo(this.$el);
+ }
+ var html = $.isFunction(this.header) ? this.header(unzippedData) : this.header;
+ this._$header.html(html);
+ }
+ },
+
+ _renderFooter: function (unzippedData) {
+ if (this.footer) {
+ if (!this._$footer) {
+ this._$footer = $('<li class="textcomplete-footer"></li>').appendTo(this.$el);
+ }
+ var html = $.isFunction(this.footer) ? this.footer(unzippedData) : this.footer;
+ this._$footer.html(html);
+ }
+ },
+
+ _renderNoResultsMessage: function (unzippedData) {
+ if (this.noResultsMessage) {
+ if (!this._$noResultsMessage) {
+ this._$noResultsMessage = $('<li class="textcomplete-no-results-message"></li>').appendTo(this.$el);
+ }
+ var html = $.isFunction(this.noResultsMessage) ? this.noResultsMessage(unzippedData) : this.noResultsMessage;
+ this._$noResultsMessage.html(html);
+ }
+ },
+
+ _renderContents: function (html) {
+ if (this._$footer) {
+ this._$footer.before(html);
+ } else {
+ this.$el.append(html);
+ }
+ },
+
+ _fitToBottom: function() {
+ var windowScrollBottom = $window.scrollTop() + $window.height();
+ var height = this.$el.height();
+ if ((this.$el.position().top + height) > windowScrollBottom) {
+ this.$el.offset({top: windowScrollBottom - height});
+ }
+ },
+
+ _applyPlacement: function (position) {
+ // If the 'placement' option set to 'top', move the position above the element.
+ if (this.placement.indexOf('top') !== -1) {
+ // Overwrite the position object to set the 'bottom' property instead of the top.
+ position = {
+ top: 'auto',
+ bottom: this.$el.parent().height() - position.top + position.lineHeight,
+ left: position.left
+ };
+ } else {
+ position.bottom = 'auto';
+ delete position.lineHeight;
+ }
+ if (this.placement.indexOf('absleft') !== -1) {
+ position.left = 0;
+ } else if (this.placement.indexOf('absright') !== -1) {
+ position.right = 0;
+ position.left = 'auto';
+ }
+ return position;
+ }
+ });
+
+ $.fn.textcomplete.Dropdown = Dropdown;
+ $.extend($.fn.textcomplete, commands);
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ // Memoize a search function.
+ var memoize = function (func) {
+ var memo = {};
+ return function (term, callback) {
+ if (memo[term]) {
+ callback(memo[term]);
+ } else {
+ func.call(this, term, function (data) {
+ memo[term] = (memo[term] || []).concat(data);
+ callback.apply(null, arguments);
+ });
+ }
+ };
+ };
+
+ function Strategy(options) {
+ $.extend(this, options);
+ if (this.cache) { this.search = memoize(this.search); }
+ }
+
+ Strategy.parse = function (strategiesArray, params) {
+ return $.map(strategiesArray, function (strategy) {
+ var strategyObj = new Strategy(strategy);
+ strategyObj.el = params.el;
+ strategyObj.$el = params.$el;
+ return strategyObj;
+ });
+ };
+
+ $.extend(Strategy.prototype, {
+ // Public properties
+ // -----------------
+
+ // Required
+ match: null,
+ replace: null,
+ search: null,
+
+ // Optional
+ cache: false,
+ context: function () { return true; },
+ index: 2,
+ template: function (obj) { return obj; },
+ idProperty: null
+ });
+
+ $.fn.textcomplete.Strategy = Strategy;
+
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ var now = Date.now || function () { return new Date().getTime(); };
+
+ // Returns a function, that, as long as it continues to be invoked, will not
+ // be triggered. The function will be called after it stops being called for
+ // `wait` msec.
+ //
+ // This utility function was originally implemented at Underscore.js.
+ var debounce = function (func, wait) {
+ var timeout, args, context, timestamp, result;
+ var later = function () {
+ var last = now() - timestamp;
+ if (last < wait) {
+ timeout = setTimeout(later, wait - last);
+ } else {
+ timeout = null;
+ result = func.apply(context, args);
+ context = args = null;
+ }
+ };
+
+ return function () {
+ context = this;
+ args = arguments;
+ timestamp = now();
+ if (!timeout) {
+ timeout = setTimeout(later, wait);
+ }
+ return result;
+ };
+ };
+
+ function Adapter () {}
+
+ $.extend(Adapter.prototype, {
+ // Public properties
+ // -----------------
+
+ id: null, // Identity.
+ completer: null, // Completer object which creates it.
+ el: null, // Textarea element.
+ $el: null, // jQuery object of the textarea.
+ option: null,
+
+ // Public methods
+ // --------------
+
+ initialize: function (element, completer, option) {
+ this.el = element;
+ this.$el = $(element);
+ this.id = completer.id + this.constructor.name;
+ this.completer = completer;
+ this.option = option;
+
+ if (this.option.debounce) {
+ this._onKeyup = debounce(this._onKeyup, this.option.debounce);
+ }
+
+ this._bindEvents();
+ },
+
+ destroy: function () {
+ this.$el.off('.' + this.id); // Remove all event handlers.
+ this.$el = this.el = this.completer = null;
+ },
+
+ // Update the element with the given value and strategy.
+ //
+ // value - The selected object. It is one of the item of the array
+ // which was callbacked from the search function.
+ // strategy - The Strategy associated with the selected value.
+ select: function (/* value, strategy */) {
+ throw new Error('Not implemented');
+ },
+
+ // Returns the caret's relative coordinates from body's left top corner.
+ //
+ // FIXME: Calculate the left top corner of `this.option.appendTo` element.
+ getCaretPosition: function () {
+ var position = this._getCaretRelativePosition();
+ var offset = this.$el.offset();
+ position.top += offset.top;
+ position.left += offset.left;
+ return position;
+ },
+
+ // Focus on the element.
+ focus: function () {
+ this.$el.focus();
+ },
+
+ // Private methods
+ // ---------------
+
+ _bindEvents: function () {
+ this.$el.on('keyup.' + this.id, $.proxy(this._onKeyup, this));
+ },
+
+ _onKeyup: function (e) {
+ if (this._skipSearch(e)) { return; }
+ this.completer.trigger(this.getTextFromHeadToCaret(), true);
+ },
+
+ // Suppress searching if it returns true.
+ _skipSearch: function (clickEvent) {
+ switch (clickEvent.keyCode) {
+ case 13: // ENTER
+ case 40: // DOWN
+ case 38: // UP
+ return true;
+ }
+ if (clickEvent.ctrlKey) switch (clickEvent.keyCode) {
+ case 78: // Ctrl-N
+ case 80: // Ctrl-P
+ return true;
+ }
+ }
+ });
+
+ $.fn.textcomplete.Adapter = Adapter;
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ // Textarea adapter
+ // ================
+ //
+ // Managing a textarea. It doesn't know a Dropdown.
+ function Textarea(element, completer, option) {
+ this.initialize(element, completer, option);
+ }
+
+ Textarea.DIV_PROPERTIES = {
+ left: -9999,
+ position: 'absolute',
+ top: 0,
+ whiteSpace: 'pre-wrap'
+ }
+
+ Textarea.COPY_PROPERTIES = [
+ 'border-width', 'font-family', 'font-size', 'font-style', 'font-variant',
+ 'font-weight', 'height', 'letter-spacing', 'word-spacing', 'line-height',
+ 'text-decoration', 'text-align', 'width', 'padding-top', 'padding-right',
+ 'padding-bottom', 'padding-left', 'margin-top', 'margin-right',
+ 'margin-bottom', 'margin-left', 'border-style', 'box-sizing', 'tab-size'
+ ];
+
+ $.extend(Textarea.prototype, $.fn.textcomplete.Adapter.prototype, {
+ // Public methods
+ // --------------
+
+ // Update the textarea with the given value and strategy.
+ select: function (value, strategy, e) {
+ var pre = this.getTextFromHeadToCaret();
+ var post = this.el.value.substring(this.el.selectionEnd);
+ var newSubstr = strategy.replace(value, e);
+ if (typeof newSubstr !== 'undefined') {
+ if ($.isArray(newSubstr)) {
+ post = newSubstr[1] + post;
+ newSubstr = newSubstr[0];
+ }
+ pre = pre.replace(strategy.match, newSubstr);
+ this.$el.val(pre + post);
+ this.el.selectionStart = this.el.selectionEnd = pre.length;
+ }
+ },
+
+ // Private methods
+ // ---------------
+
+ // Returns the caret's relative coordinates from textarea's left top corner.
+ //
+ // Browser native API does not provide the way to know the position of
+ // caret in pixels, so that here we use a kind of hack to accomplish
+ // the aim. First of all it puts a dummy div element and completely copies
+ // the textarea's style to the element, then it inserts the text and a
+ // span element into the textarea.
+ // Consequently, the span element's position is the thing what we want.
+ _getCaretRelativePosition: function () {
+ var dummyDiv = $('<div></div>').css(this._copyCss())
+ .text(this.getTextFromHeadToCaret());
+ var span = $('<span></span>').text('.').appendTo(dummyDiv);
+ this.$el.before(dummyDiv);
+ var position = span.position();
+ position.top += span.height() - this.$el.scrollTop();
+ position.lineHeight = span.height();
+ dummyDiv.remove();
+ return position;
+ },
+
+ _copyCss: function () {
+ return $.extend({
+ // Set 'scroll' if a scrollbar is being shown; otherwise 'auto'.
+ overflow: this.el.scrollHeight > this.el.offsetHeight ? 'scroll' : 'auto'
+ }, Textarea.DIV_PROPERTIES, this._getStyles());
+ },
+
+ _getStyles: (function ($) {
+ var color = $('<div></div>').css(['color']).color;
+ if (typeof color !== 'undefined') {
+ return function () {
+ return this.$el.css(Textarea.COPY_PROPERTIES);
+ };
+ } else { // jQuery < 1.8
+ return function () {
+ var $el = this.$el;
+ var styles = {};
+ $.each(Textarea.COPY_PROPERTIES, function (i, property) {
+ styles[property] = $el.css(property);
+ });
+ return styles;
+ };
+ }
+ })($),
+
+ getTextFromHeadToCaret: function () {
+ return this.el.value.substring(0, this.el.selectionEnd);
+ }
+ });
+
+ $.fn.textcomplete.Textarea = Textarea;
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ var sentinelChar = '吶';
+
+ function IETextarea(element, completer, option) {
+ this.initialize(element, completer, option);
+ $('<span>' + sentinelChar + '</span>').css({
+ position: 'absolute',
+ top: -9999,
+ left: -9999
+ }).insertBefore(element);
+ }
+
+ $.extend(IETextarea.prototype, $.fn.textcomplete.Textarea.prototype, {
+ // Public methods
+ // --------------
+
+ select: function (value, strategy, e) {
+ var pre = this.getTextFromHeadToCaret();
+ var post = this.el.value.substring(pre.length);
+ var newSubstr = strategy.replace(value, e);
+ if (typeof newSubstr !== 'undefined') {
+ if ($.isArray(newSubstr)) {
+ post = newSubstr[1] + post;
+ newSubstr = newSubstr[0];
+ }
+ pre = pre.replace(strategy.match, newSubstr);
+ this.$el.val(pre + post);
+ this.el.focus();
+ var range = this.el.createTextRange();
+ range.collapse(true);
+ range.moveEnd('character', pre.length);
+ range.moveStart('character', pre.length);
+ range.select();
+ }
+ },
+
+ getTextFromHeadToCaret: function () {
+ this.el.focus();
+ var range = document.selection.createRange();
+ range.moveStart('character', -this.el.value.length);
+ var arr = range.text.split(sentinelChar)
+ return arr.length === 1 ? arr[0] : arr[1];
+ }
+ });
+
+ $.fn.textcomplete.IETextarea = IETextarea;
+}(jQuery);
+
+// NOTE: TextComplete plugin has contenteditable support but it does not work
+// fine especially on old IEs.
+// Any pull requests are REALLY welcome.
+
++function ($) {
+ 'use strict';
+
+ // ContentEditable adapter
+ // =======================
+ //
+ // Adapter for contenteditable elements.
+ function ContentEditable (element, completer, option) {
+ this.initialize(element, completer, option);
+ }
+
+ $.extend(ContentEditable.prototype, $.fn.textcomplete.Adapter.prototype, {
+ // Public methods
+ // --------------
+
+ // Update the content with the given value and strategy.
+ // When an dropdown item is selected, it is executed.
+ select: function (value, strategy, e) {
+ var pre = this.getTextFromHeadToCaret();
+ var sel = window.getSelection()
+ var range = sel.getRangeAt(0);
+ var selection = range.cloneRange();
+ selection.selectNodeContents(range.startContainer);
+ var content = selection.toString();
+ var post = content.substring(range.startOffset);
+ var newSubstr = strategy.replace(value, e);
+ if (typeof newSubstr !== 'undefined') {
+ if ($.isArray(newSubstr)) {
+ post = newSubstr[1] + post;
+ newSubstr = newSubstr[0];
+ }
+ pre = pre.replace(strategy.match, newSubstr);
+ range.selectNodeContents(range.startContainer);
+ range.deleteContents();
+ var node = document.createTextNode(pre + post);
+ range.insertNode(node);
+ range.setStart(node, pre.length);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ },
+
+ // Private methods
+ // ---------------
+
+ // Returns the caret's relative position from the contenteditable's
+ // left top corner.
+ //
+ // Examples
+ //
+ // this._getCaretRelativePosition()
+ // //=> { top: 18, left: 200, lineHeight: 16 }
+ //
+ // Dropdown's position will be decided using the result.
+ _getCaretRelativePosition: function () {
+ var range = window.getSelection().getRangeAt(0).cloneRange();
+ var node = document.createElement('span');
+ range.insertNode(node);
+ range.selectNodeContents(node);
+ range.deleteContents();
+ var $node = $(node);
+ var position = $node.offset();
+ position.left -= this.$el.offset().left;
+ position.top += $node.height() - this.$el.offset().top;
+ position.lineHeight = $node.height();
+ $node.remove();
+ return position;
+ },
+
+ // Returns the string between the first character and the caret.
+ // Completer will be triggered with the result for start autocompleting.
+ //
+ // Example
+ //
+ // // Suppose the html is '<b>hello</b> wor|ld' and | is the caret.
+ // this.getTextFromHeadToCaret()
+ // // => ' wor' // not '<b>hello</b> wor'
+ getTextFromHeadToCaret: function () {
+ var range = window.getSelection().getRangeAt(0);
+ var selection = range.cloneRange();
+ selection.selectNodeContents(range.startContainer);
+ return selection.toString().substring(0, range.startOffset);
+ }
+ });
+
+ $.fn.textcomplete.ContentEditable = ContentEditable;
+}(jQuery);
+
+return jQuery;
+}));
+!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a){return a>1&&5>a&&1!==~~(a/10)}function d(a,b,d,e){var f=a+" ";switch(d){case"s":return b||e?"pár sekund":"pár sekundami";case"m":return b?"minuta":e?"minutu":"minutou";case"mm":return b||e?f+(c(a)?"minuty":"minut"):f+"minutami";case"h":return b?"hodina":e?"hodinu":"hodinou";case"hh":return b||e?f+(c(a)?"hodiny":"hodin"):f+"hodinami";case"d":return b||e?"den":"dnem";case"dd":return b||e?f+(c(a)?"dny":"dní"):f+"dny";case"M":return b||e?"měsíc":"měsícem";case"MM":return b||e?f+(c(a)?"měsíce":"měsíců"):f+"měsíci";case"y":return b||e?"rok":"rokem";case"yy":return b||e?f+(c(a)?"roky":"let"):f+"lety"}}var e="leden_únor_březen_duben_květen_červen_červenec_srpen_září_říjen_listopad_prosinec".split("_"),f="led_úno_bře_dub_kvě_čvn_čvc_srp_zář_říj_lis_pro".split("_");(b.defineLocale||b.lang).call(b,"cs",{months:e,monthsShort:f,monthsParse:function(a,b){var c,d=[];for(c=0;12>c;c++)d[c]=new RegExp("^"+a[c]+"$|^"+b[c]+"$","i");return d}(e,f),weekdays:"neděle_pondělí_úterý_středa_čtvrtek_pátek_sobota".split("_"),weekdaysShort:"ne_po_út_st_čt_pá_so".split("_"),weekdaysMin:"ne_po_út_st_čt_pá_so".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd D. MMMM YYYY LT"},calendar:{sameDay:"[dnes v] LT",nextDay:"[zítra v] LT",nextWeek:function(){switch(this.day()){case 0:return"[v neděli v] LT";case 1:case 2:return"[v] dddd [v] LT";case 3:return"[ve středu v] LT";case 4:return"[ve čtvrtek v] LT";case 5:return"[v pátek v] LT";case 6:return"[v sobotu v] LT"}},lastDay:"[včera v] LT",lastWeek:function(){switch(this.day()){case 0:return"[minulou neděli v] LT";case 1:case 2:return"[minulé] dddd [v] LT";case 3:return"[minulou středu v] LT";case 4:case 5:return"[minulý] dddd [v] LT";case 6:return"[minulou sobotu v] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"před %s",s:d,m:d,mm:d,h:d,hh:d,d:d,dd:d,M:d,MM:d,y:d,yy:d},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("cs","cs",{closeText:"Zavřít",prevText:"&#x3C;Dříve",nextText:"Později&#x3E;",currentText:"Nyní",monthNames:["leden","únor","březen","duben","květen","červen","červenec","srpen","září","říjen","listopad","prosinec"],monthNamesShort:["led","úno","bře","dub","kvě","čer","čvc","srp","zář","říj","lis","pro"],dayNames:["neděle","pondělí","úterý","středa","čtvrtek","pátek","sobota"],dayNamesShort:["ne","po","út","st","čt","pá","so"],dayNamesMin:["ne","po","út","st","čt","pá","so"],weekHeader:"Týd",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("cs",{buttonText:{month:"Měsíc",week:"Týden",day:"Den",list:"Agenda"},allDayText:"Celý den",eventLimitText:function(a){return"+další: "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"da",{months:"januar_februar_marts_april_maj_juni_juli_august_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"søndag_mandag_tirsdag_onsdag_torsdag_fredag_lørdag".split("_"),weekdaysShort:"søn_man_tir_ons_tor_fre_lør".split("_"),weekdaysMin:"sø_ma_ti_on_to_fr_lø".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd [d.] D. MMMM YYYY LT"},calendar:{sameDay:"[I dag kl.] LT",nextDay:"[I morgen kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[I går kl.] LT",lastWeek:"[sidste] dddd [kl] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"%s siden",s:"få sekunder",m:"et minut",mm:"%d minutter",h:"en time",hh:"%d timer",d:"en dag",dd:"%d dage",M:"en måned",MM:"%d måneder",y:"et år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("da","da",{closeText:"Luk",prevText:"&#x3C;Forrige",nextText:"Næste&#x3E;",currentText:"Idag",monthNames:["Januar","Februar","Marts","April","Maj","Juni","Juli","August","September","Oktober","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],dayNames:["Søndag","Mandag","Tirsdag","Onsdag","Torsdag","Fredag","Lørdag"],dayNamesShort:["Søn","Man","Tir","Ons","Tor","Fre","Lør"],dayNamesMin:["Sø","Ma","Ti","On","To","Fr","Lø"],weekHeader:"Uge",dateFormat:"dd-mm-yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("da",{buttonText:{month:"Måned",week:"Uge",day:"Dag",list:"Agenda"},allDayText:"Hele dagen",eventLimitText:"flere"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b,c,d){var e={m:["eine Minute","einer Minute"],h:["eine Stunde","einer Stunde"],d:["ein Tag","einem Tag"],dd:[a+" Tage",a+" Tagen"],M:["ein Monat","einem Monat"],MM:[a+" Monate",a+" Monaten"],y:["ein Jahr","einem Jahr"],yy:[a+" Jahre",a+" Jahren"]};return b?e[c][0]:e[c][1]}(b.defineLocale||b.lang).call(b,"de",{months:"Januar_Februar_März_April_Mai_Juni_Juli_August_September_Oktober_November_Dezember".split("_"),monthsShort:"Jan._Febr._Mrz._Apr._Mai_Jun._Jul._Aug._Sept._Okt._Nov._Dez.".split("_"),weekdays:"Sonntag_Montag_Dienstag_Mittwoch_Donnerstag_Freitag_Samstag".split("_"),weekdaysShort:"So._Mo._Di._Mi._Do._Fr._Sa.".split("_"),weekdaysMin:"So_Mo_Di_Mi_Do_Fr_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[Heute um] LT [Uhr]",sameElse:"L",nextDay:"[Morgen um] LT [Uhr]",nextWeek:"dddd [um] LT [Uhr]",lastDay:"[Gestern um] LT [Uhr]",lastWeek:"[letzten] dddd [um] LT [Uhr]"},relativeTime:{future:"in %s",past:"vor %s",s:"ein paar Sekunden",m:c,mm:"%d Minuten",h:c,hh:"%d Stunden",d:c,dd:c,M:c,MM:c,y:c,yy:c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("de","de",{closeText:"Schließen",prevText:"&#x3C;Zurück",nextText:"Vor&#x3E;",currentText:"Heute",monthNames:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],monthNamesShort:["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],dayNames:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"],dayNamesShort:["So","Mo","Di","Mi","Do","Fr","Sa"],dayNamesMin:["So","Mo","Di","Mi","Do","Fr","Sa"],weekHeader:"KW",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("de",{buttonText:{month:"Monat",week:"Woche",day:"Tag",list:"Terminübersicht"},allDayText:"Ganztägig",eventLimitText:function(a){return"+ weitere "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c="ene._feb._mar._abr._may._jun._jul._ago._sep._oct._nov._dic.".split("_"),d="ene_feb_mar_abr_may_jun_jul_ago_sep_oct_nov_dic".split("_");(b.defineLocale||b.lang).call(b,"es",{months:"enero_febrero_marzo_abril_mayo_junio_julio_agosto_septiembre_octubre_noviembre_diciembre".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?d[a.month()]:c[a.month()]},weekdays:"domingo_lunes_martes_miércoles_jueves_viernes_sábado".split("_"),weekdaysShort:"dom._lun._mar._mié._jue._vie._sáb.".split("_"),weekdaysMin:"Do_Lu_Ma_Mi_Ju_Vi_Sá".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY LT",LLLL:"dddd, D [de] MMMM [de] YYYY LT"},calendar:{sameDay:function(){return"[hoy a la"+(1!==this.hours()?"s":"")+"] LT"},nextDay:function(){return"[mañana a la"+(1!==this.hours()?"s":"")+"] LT"},nextWeek:function(){return"dddd [a la"+(1!==this.hours()?"s":"")+"] LT"},lastDay:function(){return"[ayer a la"+(1!==this.hours()?"s":"")+"] LT"},lastWeek:function(){return"[el] dddd [pasado a la"+(1!==this.hours()?"s":"")+"] LT"},sameElse:"L"},relativeTime:{future:"en %s",past:"hace %s",s:"unos segundos",m:"un minuto",mm:"%d minutos",h:"una hora",hh:"%d horas",d:"un día",dd:"%d días",M:"un mes",MM:"%d meses",y:"un año",yy:"%d años"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("es","es",{closeText:"Cerrar",prevText:"&#x3C;Ant",nextText:"Sig&#x3E;",currentText:"Hoy",monthNames:["enero","febrero","marzo","abril","mayo","junio","julio","agosto","septiembre","octubre","noviembre","diciembre"],monthNamesShort:["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"],dayNames:["domingo","lunes","martes","miércoles","jueves","viernes","sábado"],dayNamesShort:["dom","lun","mar","mié","jue","vie","sáb"],dayNamesMin:["D","L","M","X","J","V","S"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("es",{buttonText:{month:"Mes",week:"Semana",day:"Día",list:"Agenda"},allDayHtml:"Todo<br/>el día",eventLimitText:"más"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"el",{monthsNominativeEl:"Ιανουάριος_Φεβρουάριος_Μάρτιος_Απρίλιος_Μάιος_Ιούνιος_Ιούλιος_Αύγουστος_Σεπτέμβριος_Οκτώβριος_Νοέμβριος_Δεκέμβριος".split("_"),monthsGenitiveEl:"Ιανουαρίου_Φεβρουαρίου_Μαρτίου_Απριλίου_Μαΐου_Ιουνίου_Ιουλίου_Αυγούστου_Σεπτεμβρίου_Οκτωβρίου_Νοεμβρίου_Δεκεμβρίου".split("_"),months:function(a,b){return/D/.test(b.substring(0,b.indexOf("MMMM")))?this._monthsGenitiveEl[a.month()]:this._monthsNominativeEl[a.month()]},monthsShort:"Ιαν_Φεβ_Μαρ_Απρ_Μαϊ_Ιουν_Ιουλ_Αυγ_Σεπ_Οκτ_Νοε_Δεκ".split("_"),weekdays:"Κυριακή_Δευτέρα_Τρίτη_Τετάρτη_Πέμπτη_Παρασκευή_Σάββατο".split("_"),weekdaysShort:"Κυρ_Δευ_Τρι_Τετ_Πεμ_Παρ_Σαβ".split("_"),weekdaysMin:"Κυ_Δε_Τρ_Τε_Πε_Πα_Σα".split("_"),meridiem:function(a,b,c){return a>11?c?"μμ":"ΜΜ":c?"πμ":"ΠΜ"},isPM:function(a){return"μ"===(a+"").toLowerCase()[0]},meridiemParse:/[ΠΜ]\.?Μ?\.?/i,longDateFormat:{LT:"h:mm A",LTS:"h:mm:ss A",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendarEl:{sameDay:"[Σήμερα {}] LT",nextDay:"[Αύριο {}] LT",nextWeek:"dddd [{}] LT",lastDay:"[Χθες {}] LT",lastWeek:function(){switch(this.day()){case 6:return"[το προηγούμενο] dddd [{}] LT";default:return"[την προηγούμενη] dddd [{}] LT"}},sameElse:"L"},calendar:function(a,b){var c=this._calendarEl[a],d=b&&b.hours();return"function"==typeof c&&(c=c.apply(b)),c.replace("{}",d%12===1?"στη":"στις")},relativeTime:{future:"σε %s",past:"%s πριν",s:"λίγα δευτερόλεπτα",m:"ένα λεπτό",mm:"%d λεπτά",h:"μία ώρα",hh:"%d ώρες",d:"μία μέρα",dd:"%d μέρες",M:"ένας μήνας",MM:"%d μήνες",y:"ένας χρόνος",yy:"%d χρόνια"},ordinalParse:/\d{1,2}η/,ordinal:"%dη",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("el","el",{closeText:"Κλείσιμο",prevText:"Προηγούμενος",nextText:"Επόμενος",currentText:"Σήμερα",monthNames:["Ιανουάριος","Φεβρουάριος","Μάρτιος","Απρίλιος","Μάιος","Ιούνιος","Ιούλιος","Αύγουστος","Σεπτέμβριος","Οκτώβριος","Νοέμβριος","Δεκέμβριος"],monthNamesShort:["Ιαν","Φεβ","Μαρ","Απρ","Μαι","Ιουν","Ιουλ","Αυγ","Σεπ","Οκτ","Νοε","Δεκ"],dayNames:["Κυριακή","Δευτέρα","Τρίτη","Τετάρτη","Πέμπτη","Παρασκευή","Σάββατο"],dayNamesShort:["Κυρ","Δευ","Τρι","Τετ","Πεμ","Παρ","Σαβ"],dayNamesMin:["Κυ","Δε","Τρ","Τε","Πε","Πα","Σα"],weekHeader:"Εβδ",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("el",{buttonText:{month:"Μήνας",week:"Εβδομάδα",day:"Ημέρα",list:"Ατζέντα"},allDayText:"Ολοήμερο",eventLimitText:"περισσότερα"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b,c,e){var f="";switch(c){case"s":return e?"muutaman sekunnin":"muutama sekunti";case"m":return e?"minuutin":"minuutti";case"mm":f=e?"minuutin":"minuuttia";break;case"h":return e?"tunnin":"tunti";case"hh":f=e?"tunnin":"tuntia";break;case"d":return e?"päivän":"päivä";case"dd":f=e?"päivän":"päivää";break;case"M":return e?"kuukauden":"kuukausi";case"MM":f=e?"kuukauden":"kuukautta";break;case"y":return e?"vuoden":"vuosi";case"yy":f=e?"vuoden":"vuotta"}return f=d(a,e)+" "+f}function d(a,b){return 10>a?b?f[a]:e[a]:a}var e="nolla yksi kaksi kolme neljä viisi kuusi seitsemän kahdeksan yhdeksän".split(" "),f=["nolla","yhden","kahden","kolmen","neljän","viiden","kuuden",e[7],e[8],e[9]];(b.defineLocale||b.lang).call(b,"fi",{months:"tammikuu_helmikuu_maaliskuu_huhtikuu_toukokuu_kesäkuu_heinäkuu_elokuu_syyskuu_lokakuu_marraskuu_joulukuu".split("_"),monthsShort:"tammi_helmi_maalis_huhti_touko_kesä_heinä_elo_syys_loka_marras_joulu".split("_"),weekdays:"sunnuntai_maanantai_tiistai_keskiviikko_torstai_perjantai_lauantai".split("_"),weekdaysShort:"su_ma_ti_ke_to_pe_la".split("_"),weekdaysMin:"su_ma_ti_ke_to_pe_la".split("_"),longDateFormat:{LT:"HH.mm",LTS:"HH.mm.ss",L:"DD.MM.YYYY",LL:"Do MMMM[ta] YYYY",LLL:"Do MMMM[ta] YYYY, [klo] LT",LLLL:"dddd, Do MMMM[ta] YYYY, [klo] LT",l:"D.M.YYYY",ll:"Do MMM YYYY",lll:"Do MMM YYYY, [klo] LT",llll:"ddd, Do MMM YYYY, [klo] LT"},calendar:{sameDay:"[tänään] [klo] LT",nextDay:"[huomenna] [klo] LT",nextWeek:"dddd [klo] LT",lastDay:"[eilen] [klo] LT",lastWeek:"[viime] dddd[na] [klo] LT",sameElse:"L"},relativeTime:{future:"%s päästä",past:"%s sitten",s:c,m:c,mm:c,h:c,hh:c,d:c,dd:c,M:c,MM:c,y:c,yy:c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("fi","fi",{closeText:"Sulje",prevText:"&#xAB;Edellinen",nextText:"Seuraava&#xBB;",currentText:"Tänään",monthNames:["Tammikuu","Helmikuu","Maaliskuu","Huhtikuu","Toukokuu","Kesäkuu","Heinäkuu","Elokuu","Syyskuu","Lokakuu","Marraskuu","Joulukuu"],monthNamesShort:["Tammi","Helmi","Maalis","Huhti","Touko","Kesä","Heinä","Elo","Syys","Loka","Marras","Joulu"],dayNamesShort:["Su","Ma","Ti","Ke","To","Pe","La"],dayNames:["Sunnuntai","Maanantai","Tiistai","Keskiviikko","Torstai","Perjantai","Lauantai"],dayNamesMin:["Su","Ma","Ti","Ke","To","Pe","La"],weekHeader:"Vk",dateFormat:"d.m.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("fi",{buttonText:{month:"Kuukausi",week:"Viikko",day:"Päivä",list:"Tapahtumat"},allDayText:"Koko päivä",eventLimitText:"lisää"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"fr",{months:"janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split("_"),monthsShort:"janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split("_"),weekdays:"dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi".split("_"),weekdaysShort:"dim._lun._mar._mer._jeu._ven._sam.".split("_"),weekdaysMin:"Di_Lu_Ma_Me_Je_Ve_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Aujourd'hui à] LT",nextDay:"[Demain à] LT",nextWeek:"dddd [à] LT",lastDay:"[Hier à] LT",lastWeek:"dddd [dernier à] LT",sameElse:"L"},relativeTime:{future:"dans %s",past:"il y a %s",s:"quelques secondes",m:"une minute",mm:"%d minutes",h:"une heure",hh:"%d heures",d:"un jour",dd:"%d jours",M:"un mois",MM:"%d mois",y:"un an",yy:"%d ans"},ordinalParse:/\d{1,2}(er|)/,ordinal:function(a){return a+(1===a?"er":"")},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("fr","fr",{closeText:"Fermer",prevText:"Précédent",nextText:"Suivant",currentText:"Aujourd'hui",monthNames:["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],monthNamesShort:["janv.","févr.","mars","avr.","mai","juin","juil.","août","sept.","oct.","nov.","déc."],dayNames:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"],dayNamesShort:["dim.","lun.","mar.","mer.","jeu.","ven.","sam."],dayNamesMin:["D","L","M","M","J","V","S"],weekHeader:"Sem.",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("fr",{buttonText:{month:"Mois",week:"Semaine",day:"Jour",list:"Mon planning"},allDayHtml:"Toute la<br/>journée",eventLimitText:"en plus"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b,c,d){var e=a;switch(c){case"s":return d||b?"néhány másodperc":"néhány másodperce";case"m":return"egy"+(d||b?" perc":" perce");case"mm":return e+(d||b?" perc":" perce");case"h":return"egy"+(d||b?" óra":" órája");case"hh":return e+(d||b?" óra":" órája");case"d":return"egy"+(d||b?" nap":" napja");case"dd":return e+(d||b?" nap":" napja");case"M":return"egy"+(d||b?" hónap":" hónapja");case"MM":return e+(d||b?" hónap":" hónapja");case"y":return"egy"+(d||b?" év":" éve");case"yy":return e+(d||b?" év":" éve")}return""}function d(a){return(a?"":"[múlt] ")+"["+e[this.day()]+"] LT[-kor]"}var e="vasárnap hétfőn kedden szerdán csütörtökön pénteken szombaton".split(" ");(b.defineLocale||b.lang).call(b,"hu",{months:"január_február_március_április_május_június_július_augusztus_szeptember_október_november_december".split("_"),monthsShort:"jan_feb_márc_ápr_máj_jún_júl_aug_szept_okt_nov_dec".split("_"),weekdays:"vasárnap_hétfő_kedd_szerda_csütörtök_péntek_szombat".split("_"),weekdaysShort:"vas_hét_kedd_sze_csüt_pén_szo".split("_"),weekdaysMin:"v_h_k_sze_cs_p_szo".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"YYYY.MM.DD.",LL:"YYYY. MMMM D.",LLL:"YYYY. MMMM D., LT",LLLL:"YYYY. MMMM D., dddd LT"},meridiemParse:/de|du/i,isPM:function(a){return"u"===a.charAt(1).toLowerCase()},meridiem:function(a,b,c){return 12>a?c===!0?"de":"DE":c===!0?"du":"DU"},calendar:{sameDay:"[ma] LT[-kor]",nextDay:"[holnap] LT[-kor]",nextWeek:function(){return d.call(this,!0)},lastDay:"[tegnap] LT[-kor]",lastWeek:function(){return d.call(this,!1)},sameElse:"L"},relativeTime:{future:"%s múlva",past:"%s",s:c,m:c,mm:c,h:c,hh:c,d:c,dd:c,M:c,MM:c,y:c,yy:c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("hu","hu",{closeText:"bezár",prevText:"vissza",nextText:"előre",currentText:"ma",monthNames:["Január","Február","Március","Április","Május","Június","Július","Augusztus","Szeptember","Október","November","December"],monthNamesShort:["Jan","Feb","Már","Ápr","Máj","Jún","Júl","Aug","Szep","Okt","Nov","Dec"],dayNames:["Vasárnap","Hétfő","Kedd","Szerda","Csütörtök","Péntek","Szombat"],dayNamesShort:["Vas","Hét","Ked","Sze","Csü","Pén","Szo"],dayNamesMin:["V","H","K","Sze","Cs","P","Szo"],weekHeader:"Hét",dateFormat:"yy.mm.dd.",firstDay:1,isRTL:!1,showMonthAfterYear:!0,yearSuffix:""}),a.fullCalendar.lang("hu",{buttonText:{month:"Hónap",week:"Hét",day:"Nap",list:"Napló"},allDayText:"Egész nap",eventLimitText:"további"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"id",{months:"Januari_Februari_Maret_April_Mei_Juni_Juli_Agustus_September_Oktober_November_Desember".split("_"),monthsShort:"Jan_Feb_Mar_Apr_Mei_Jun_Jul_Ags_Sep_Okt_Nov_Des".split("_"),weekdays:"Minggu_Senin_Selasa_Rabu_Kamis_Jumat_Sabtu".split("_"),weekdaysShort:"Min_Sen_Sel_Rab_Kam_Jum_Sab".split("_"),weekdaysMin:"Mg_Sn_Sl_Rb_Km_Jm_Sb".split("_"),longDateFormat:{LT:"HH.mm",LTS:"LT.ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY [pukul] LT",LLLL:"dddd, D MMMM YYYY [pukul] LT"},meridiemParse:/pagi|siang|sore|malam/,meridiemHour:function(a,b){return 12===a&&(a=0),"pagi"===b?a:"siang"===b?a>=11?a:a+12:"sore"===b||"malam"===b?a+12:void 0},meridiem:function(a,b,c){return 11>a?"pagi":15>a?"siang":19>a?"sore":"malam"},calendar:{sameDay:"[Hari ini pukul] LT",nextDay:"[Besok pukul] LT",nextWeek:"dddd [pukul] LT",lastDay:"[Kemarin pukul] LT",lastWeek:"dddd [lalu pukul] LT",sameElse:"L"},relativeTime:{future:"dalam %s",past:"%s yang lalu",s:"beberapa detik",m:"semenit",mm:"%d menit",h:"sejam",hh:"%d jam",d:"sehari",dd:"%d hari",M:"sebulan",MM:"%d bulan",y:"setahun",yy:"%d tahun"},week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("id","id",{closeText:"Tutup",prevText:"&#x3C;mundur",nextText:"maju&#x3E;",currentText:"hari ini",monthNames:["Januari","Februari","Maret","April","Mei","Juni","Juli","Agustus","September","Oktober","Nopember","Desember"],monthNamesShort:["Jan","Feb","Mar","Apr","Mei","Jun","Jul","Agus","Sep","Okt","Nop","Des"],dayNames:["Minggu","Senin","Selasa","Rabu","Kamis","Jumat","Sabtu"],dayNamesShort:["Min","Sen","Sel","Rab","kam","Jum","Sab"],dayNamesMin:["Mg","Sn","Sl","Rb","Km","jm","Sb"],weekHeader:"Mg",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("id",{buttonText:{month:"Bulan",week:"Minggu",day:"Hari",list:"Agenda"},allDayHtml:"Sehari<br/>penuh",eventLimitText:"lebih"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"it",{months:"gennaio_febbraio_marzo_aprile_maggio_giugno_luglio_agosto_settembre_ottobre_novembre_dicembre".split("_"),monthsShort:"gen_feb_mar_apr_mag_giu_lug_ago_set_ott_nov_dic".split("_"),weekdays:"Domenica_Lunedì_Martedì_Mercoledì_Giovedì_Venerdì_Sabato".split("_"),weekdaysShort:"Dom_Lun_Mar_Mer_Gio_Ven_Sab".split("_"),weekdaysMin:"D_L_Ma_Me_G_V_S".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Oggi alle] LT",nextDay:"[Domani alle] LT",nextWeek:"dddd [alle] LT",lastDay:"[Ieri alle] LT",lastWeek:function(){switch(this.day()){case 0:return"[la scorsa] dddd [alle] LT";default:return"[lo scorso] dddd [alle] LT"}},sameElse:"L"},relativeTime:{future:function(a){return(/^[0-9].+$/.test(a)?"tra":"in")+" "+a},past:"%s fa",s:"alcuni secondi",m:"un minuto",mm:"%d minuti",h:"un'ora",hh:"%d ore",d:"un giorno",dd:"%d giorni",M:"un mese",MM:"%d mesi",y:"un anno",yy:"%d anni"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("it","it",{closeText:"Chiudi",prevText:"&#x3C;Prec",nextText:"Succ&#x3E;",currentText:"Oggi",monthNames:["Gennaio","Febbraio","Marzo","Aprile","Maggio","Giugno","Luglio","Agosto","Settembre","Ottobre","Novembre","Dicembre"],monthNamesShort:["Gen","Feb","Mar","Apr","Mag","Giu","Lug","Ago","Set","Ott","Nov","Dic"],dayNames:["Domenica","Lunedì","Martedì","Mercoledì","Giovedì","Venerdì","Sabato"],dayNamesShort:["Dom","Lun","Mar","Mer","Gio","Ven","Sab"],dayNamesMin:["Do","Lu","Ma","Me","Gi","Ve","Sa"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("it",{buttonText:{month:"Mese",week:"Settimana",day:"Giorno",list:"Agenda"},allDayHtml:"Tutto il<br/>giorno",eventLimitText:function(a){return"+altri "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"ja",{months:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"日曜日_月曜日_火曜日_水曜日_木曜日_金曜日_土曜日".split("_"),weekdaysShort:"日_月_火_水_木_金_土".split("_"),weekdaysMin:"日_月_火_水_木_金_土".split("_"),longDateFormat:{LT:"Ah時m分",LTS:"LTs秒",L:"YYYY/MM/DD",LL:"YYYY年M月D日",LLL:"YYYY年M月D日LT",LLLL:"YYYY年M月D日LT dddd"},meridiemParse:/午前|午後/i,isPM:function(a){return"午後"===a},meridiem:function(a,b,c){return 12>a?"午前":"午後"},calendar:{sameDay:"[今日] LT",nextDay:"[明日] LT",nextWeek:"[来週]dddd LT",lastDay:"[昨日] LT",lastWeek:"[前週]dddd LT",sameElse:"L"},relativeTime:{future:"%s後",past:"%s前",s:"数秒",m:"1分",mm:"%d分",h:"1時間",hh:"%d時間",d:"1日",dd:"%d日",M:"1ヶ月",MM:"%dヶ月",y:"1年",yy:"%d年"}}),a.fullCalendar.datepickerLang("ja","ja",{closeText:"閉じる",prevText:"&#x3C;前",nextText:"次&#x3E;",currentText:"今日",monthNames:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],monthNamesShort:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayNames:["日曜日","月曜日","火曜日","水曜日","木曜日","金曜日","土曜日"],dayNamesShort:["日","月","火","水","木","金","土"],dayNamesMin:["日","月","火","水","木","金","土"],weekHeader:"週",dateFormat:"yy/mm/dd",firstDay:0,isRTL:!1,showMonthAfterYear:!0,yearSuffix:"年"}),a.fullCalendar.lang("ja",{buttonText:{month:"月",week:"週",day:"日",list:"予定リスト"},allDayText:"終日",eventLimitText:function(a){return"他 "+a+" 件"}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c="jan._feb._mrt._apr._mei_jun._jul._aug._sep._okt._nov._dec.".split("_"),d="jan_feb_mrt_apr_mei_jun_jul_aug_sep_okt_nov_dec".split("_");(b.defineLocale||b.lang).call(b,"nl",{months:"januari_februari_maart_april_mei_juni_juli_augustus_september_oktober_november_december".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?d[a.month()]:c[a.month()]},weekdays:"zondag_maandag_dinsdag_woensdag_donderdag_vrijdag_zaterdag".split("_"),weekdaysShort:"zo._ma._di._wo._do._vr._za.".split("_"),weekdaysMin:"Zo_Ma_Di_Wo_Do_Vr_Za".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD-MM-YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[vandaag om] LT",nextDay:"[morgen om] LT",nextWeek:"dddd [om] LT",lastDay:"[gisteren om] LT",lastWeek:"[afgelopen] dddd [om] LT",sameElse:"L"},relativeTime:{future:"over %s",past:"%s geleden",s:"een paar seconden",m:"één minuut",mm:"%d minuten",h:"één uur",hh:"%d uur",d:"één dag",dd:"%d dagen",M:"één maand",MM:"%d maanden",y:"één jaar",yy:"%d jaar"},ordinalParse:/\d{1,2}(ste|de)/,ordinal:function(a){return a+(1===a||8===a||a>=20?"ste":"de")},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("nl","nl",{closeText:"Sluiten",prevText:"←",nextText:"→",currentText:"Vandaag",monthNames:["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"],monthNamesShort:["jan","feb","mrt","apr","mei","jun","jul","aug","sep","okt","nov","dec"],dayNames:["zondag","maandag","dinsdag","woensdag","donderdag","vrijdag","zaterdag"],dayNamesShort:["zon","maa","din","woe","don","vri","zat"],dayNamesMin:["zo","ma","di","wo","do","vr","za"],weekHeader:"Wk",dateFormat:"dd-mm-yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("nl",{buttonText:{month:"Maand",week:"Week",day:"Dag",list:"Agenda"},allDayText:"Hele dag",eventLimitText:"extra"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"nb",{months:"januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split("_"),monthsShort:"jan_feb_mar_apr_mai_jun_jul_aug_sep_okt_nov_des".split("_"),weekdays:"søndag_mandag_tirsdag_onsdag_torsdag_fredag_lørdag".split("_"),weekdaysShort:"søn_man_tirs_ons_tors_fre_lør".split("_"),weekdaysMin:"sø_ma_ti_on_to_fr_lø".split("_"),longDateFormat:{LT:"H.mm",LTS:"LT.ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY [kl.] LT",LLLL:"dddd D. MMMM YYYY [kl.] LT"},calendar:{sameDay:"[i dag kl.] LT",nextDay:"[i morgen kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[i går kl.] LT",lastWeek:"[forrige] dddd [kl.] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"for %s siden",s:"noen sekunder",m:"ett minutt",mm:"%d minutter",h:"en time",hh:"%d timer",d:"en dag",dd:"%d dager",M:"en måned",MM:"%d måneder",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("nb","nb",{closeText:"Lukk",prevText:"&#xAB;Forrige",nextText:"Neste&#xBB;",currentText:"I dag",monthNames:["januar","februar","mars","april","mai","juni","juli","august","september","oktober","november","desember"],monthNamesShort:["jan","feb","mar","apr","mai","jun","jul","aug","sep","okt","nov","des"],dayNamesShort:["søn","man","tir","ons","tor","fre","lør"],dayNames:["søndag","mandag","tirsdag","onsdag","torsdag","fredag","lørdag"],dayNamesMin:["sø","ma","ti","on","to","fr","lø"],weekHeader:"Uke",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("nb",{buttonText:{month:"Måned",week:"Uke",day:"Dag",list:"Agenda"},allDayText:"Hele dagen",eventLimitText:"til"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a){return 5>a%10&&a%10>1&&~~(a/10)%10!==1}function d(a,b,d){var e=a+" ";switch(d){case"m":return b?"minuta":"minutę";case"mm":return e+(c(a)?"minuty":"minut");case"h":return b?"godzina":"godzinę";case"hh":return e+(c(a)?"godziny":"godzin");case"MM":return e+(c(a)?"miesiące":"miesięcy");case"yy":return e+(c(a)?"lata":"lat")}}var e="styczeń_luty_marzec_kwiecień_maj_czerwiec_lipiec_sierpień_wrzesień_październik_listopad_grudzień".split("_"),f="stycznia_lutego_marca_kwietnia_maja_czerwca_lipca_sierpnia_września_października_listopada_grudnia".split("_");(b.defineLocale||b.lang).call(b,"pl",{months:function(a,b){return/D MMMM/.test(b)?f[a.month()]:e[a.month()]},monthsShort:"sty_lut_mar_kwi_maj_cze_lip_sie_wrz_paź_lis_gru".split("_"),weekdays:"niedziela_poniedziałek_wtorek_środa_czwartek_piątek_sobota".split("_"),weekdaysShort:"nie_pon_wt_śr_czw_pt_sb".split("_"),weekdaysMin:"N_Pn_Wt_Śr_Cz_Pt_So".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Dziś o] LT",nextDay:"[Jutro o] LT",nextWeek:"[W] dddd [o] LT",lastDay:"[Wczoraj o] LT",lastWeek:function(){switch(this.day()){case 0:return"[W zeszłą niedzielę o] LT";case 3:return"[W zeszłą środę o] LT";case 6:return"[W zeszłą sobotę o] LT";default:return"[W zeszły] dddd [o] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"%s temu",s:"kilka sekund",m:d,mm:d,h:d,hh:d,d:"1 dzień",dd:"%d dni",M:"miesiąc",MM:d,y:"rok",yy:d},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("pl","pl",{closeText:"Zamknij",prevText:"&#x3C;Poprzedni",nextText:"Następny&#x3E;",currentText:"Dziś",monthNames:["Styczeń","Luty","Marzec","Kwiecień","Maj","Czerwiec","Lipiec","Sierpień","Wrzesień","Październik","Listopad","Grudzień"],monthNamesShort:["Sty","Lu","Mar","Kw","Maj","Cze","Lip","Sie","Wrz","Pa","Lis","Gru"],dayNames:["Niedziela","Poniedziałek","Wtorek","Środa","Czwartek","Piątek","Sobota"],dayNamesShort:["Nie","Pn","Wt","Śr","Czw","Pt","So"],dayNamesMin:["N","Pn","Wt","Śr","Cz","Pt","So"],weekHeader:"Tydz",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("pl",{buttonText:{month:"Miesiąc",week:"Tydzień",day:"Dzień",list:"Plan dnia"},allDayText:"Cały dzień",eventLimitText:"więcej"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"pt",{months:"janeiro_fevereiro_março_abril_maio_junho_julho_agosto_setembro_outubro_novembro_dezembro".split("_"),monthsShort:"jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez".split("_"),weekdays:"domingo_segunda-feira_terça-feira_quarta-feira_quinta-feira_sexta-feira_sábado".split("_"),weekdaysShort:"dom_seg_ter_qua_qui_sex_sáb".split("_"),weekdaysMin:"dom_2ª_3ª_4ª_5ª_6ª_sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY LT",LLLL:"dddd, D [de] MMMM [de] YYYY LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"há %s",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("pt","pt",{closeText:"Fechar",prevText:"Anterior",nextText:"Seguinte",currentText:"Hoje",monthNames:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthNamesShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],dayNames:["Domingo","Segunda-feira","Terça-feira","Quarta-feira","Quinta-feira","Sexta-feira","Sábado"],dayNamesShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],dayNamesMin:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],weekHeader:"Sem",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("pt",{buttonText:{month:"Mês",week:"Semana",day:"Dia",list:"Agenda"},allDayText:"Todo o dia",eventLimitText:"mais"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"pt-br",{months:"janeiro_fevereiro_março_abril_maio_junho_julho_agosto_setembro_outubro_novembro_dezembro".split("_"),monthsShort:"jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez".split("_"),weekdays:"domingo_segunda-feira_terça-feira_quarta-feira_quinta-feira_sexta-feira_sábado".split("_"),weekdaysShort:"dom_seg_ter_qua_qui_sex_sáb".split("_"),weekdaysMin:"dom_2ª_3ª_4ª_5ª_6ª_sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY [às] LT",LLLL:"dddd, D [de] MMMM [de] YYYY [às] LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"%s atrás",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº"}),a.fullCalendar.datepickerLang("pt-br","pt-BR",{closeText:"Fechar",prevText:"&#x3C;Anterior",nextText:"Próximo&#x3E;",currentText:"Hoje",monthNames:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthNamesShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],dayNames:["Domingo","Segunda-feira","Terça-feira","Quarta-feira","Quinta-feira","Sexta-feira","Sábado"],dayNamesShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],dayNamesMin:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("pt-br",{buttonText:{month:"Mês",week:"Semana",day:"Dia",list:"Compromissos"},allDayText:"dia inteiro",eventLimitText:function(a){return"mais +"+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){function c(a,b){var c=a.split("_");return b%10===1&&b%100!==11?c[0]:b%10>=2&&4>=b%10&&(10>b%100||b%100>=20)?c[1]:c[2]}function d(a,b,d){var e={mm:b?"минута_минуты_минут":"минуту_минуты_минут",hh:"час_часа_часов",dd:"день_дня_дней",MM:"месяц_месяца_месяцев",yy:"год_года_лет"};return"m"===d?b?"минута":"минуту":a+" "+c(e[d],+a)}function e(a,b){var c={nominative:"январь_февраль_март_апрель_май_июнь_июль_август_сентябрь_октябрь_ноябрь_декабрь".split("_"),accusative:"января_февраля_марта_апреля_мая_июня_июля_августа_сентября_октября_ноября_декабря".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function f(a,b){var c={nominative:"янв_фев_март_апр_май_июнь_июль_авг_сен_окт_ноя_дек".split("_"),accusative:"янв_фев_мар_апр_мая_июня_июля_авг_сен_окт_ноя_дек".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function g(a,b){var c={nominative:"воскресенье_понедельник_вторник_среда_четверг_пятница_суббота".split("_"),accusative:"воскресенье_понедельник_вторник_среду_четверг_пятницу_субботу".split("_")},d=/\[ ?[Вв] ?(?:прошлую|следующую|эту)? ?\] ?dddd/.test(b)?"accusative":"nominative";return c[d][a.day()]}(b.defineLocale||b.lang).call(b,"ru",{months:e,monthsShort:f,weekdays:g,weekdaysShort:"вс_пн_вт_ср_чт_пт_сб".split("_"),weekdaysMin:"вс_пн_вт_ср_чт_пт_сб".split("_"),monthsParse:[/^янв/i,/^фев/i,/^мар/i,/^апр/i,/^ма[й|я]/i,/^июн/i,/^июл/i,/^авг/i,/^сен/i,/^окт/i,/^ноя/i,/^дек/i],longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY г.",LLL:"D MMMM YYYY г., LT",LLLL:"dddd, D MMMM YYYY г., LT"},calendar:{sameDay:"[Сегодня в] LT",nextDay:"[Завтра в] LT",lastDay:"[Вчера в] LT",nextWeek:function(){return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT"},lastWeek:function(a){if(a.week()===this.week())return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT";switch(this.day()){case 0:return"[В прошлое] dddd [в] LT";case 1:case 2:case 4:return"[В прошлый] dddd [в] LT";case 3:case 5:case 6:return"[В прошлую] dddd [в] LT"}},sameElse:"L"},relativeTime:{future:"через %s",past:"%s назад",s:"несколько секунд",m:d,mm:d,h:"час",hh:d,d:"день",dd:d,M:"месяц",MM:d,y:"год",yy:d},meridiemParse:/ночи|утра|дня|вечера/i,isPM:function(a){return/^(дня|вечера)$/.test(a)},meridiem:function(a,b,c){return 4>a?"ночи":12>a?"утра":17>a?"дня":"вечера"},ordinalParse:/\d{1,2}-(й|го|я)/,ordinal:function(a,b){switch(b){case"M":case"d":case"DDD":return a+"-й";case"D":return a+"-го";case"w":case"W":return a+"-я";default:return a}},week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("ru","ru",{closeText:"Закрыть",prevText:"&#x3C;Пред",nextText:"След&#x3E;",currentText:"Сегодня",monthNames:["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"],monthNamesShort:["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"],dayNames:["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"],dayNamesShort:["вск","пнд","втр","срд","чтв","птн","сбт"],dayNamesMin:["Вс","Пн","Вт","Ср","Чт","Пт","Сб"],weekHeader:"Нед",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("ru",{buttonText:{month:"Месяц",week:"Неделя",day:"День",list:"Повестка дня"},allDayText:"Весь день",eventLimitText:function(a){return"+ ещё "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"sv",{months:"januari_februari_mars_april_maj_juni_juli_augusti_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"söndag_måndag_tisdag_onsdag_torsdag_fredag_lördag".split("_"),weekdaysShort:"sön_mån_tis_ons_tor_fre_lör".split("_"),weekdaysMin:"sö_må_ti_on_to_fr_lö".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Idag] LT",nextDay:"[Imorgon] LT",lastDay:"[Igår] LT",nextWeek:"dddd LT",lastWeek:"[Förra] dddd[en] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"för %s sedan",s:"några sekunder",m:"en minut",mm:"%d minuter",h:"en timme",hh:"%d timmar",d:"en dag",dd:"%d dagar",M:"en månad",MM:"%d månader",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}(e|a)/,ordinal:function(a){var b=a%10,c=1===~~(a%100/10)?"e":1===b?"a":2===b?"a":"e";return a+c},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("sv","sv",{closeText:"Stäng",prevText:"&#xAB;Förra",nextText:"Nästa&#xBB;",currentText:"Idag",monthNames:["Januari","Februari","Mars","April","Maj","Juni","Juli","Augusti","September","Oktober","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],dayNamesShort:["Sön","Mån","Tis","Ons","Tor","Fre","Lör"],dayNames:["Söndag","Måndag","Tisdag","Onsdag","Torsdag","Fredag","Lördag"],dayNamesMin:["Sö","Må","Ti","On","To","Fr","Lö"],weekHeader:"Ve",dateFormat:"yy-mm-dd",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("sv",{buttonText:{month:"Månad",week:"Vecka",day:"Dag",list:"Program"},allDayText:"Heldag",eventLimitText:"till"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c={words:{m:["jedan minut","jedne minute"],mm:["minut","minute","minuta"],h:["jedan sat","jednog sata"],hh:["sat","sata","sati"],dd:["dan","dana","dana"],MM:["mesec","meseca","meseci"],yy:["godina","godine","godina"]},correctGrammaticalCase:function(a,b){return 1===a?b[0]:a>=2&&4>=a?b[1]:b[2]},translate:function(a,b,d){var e=c.words[d];return 1===d.length?b?e[0]:e[1]:a+" "+c.correctGrammaticalCase(a,e)}};(b.defineLocale||b.lang).call(b,"sr",{months:["januar","februar","mart","april","maj","jun","jul","avgust","septembar","oktobar","novembar","decembar"],monthsShort:["jan.","feb.","mar.","apr.","maj","jun","jul","avg.","sep.","okt.","nov.","dec."],weekdays:["nedelja","ponedeljak","utorak","sreda","četvrtak","petak","subota"],weekdaysShort:["ned.","pon.","uto.","sre.","čet.","pet.","sub."],weekdaysMin:["ne","po","ut","sr","če","pe","su"],longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedelju] [u] LT";case 3:return"[u] [sredu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[juče u] LT",lastWeek:function(){var a=["[prošle] [nedelje] [u] LT","[prošlog] [ponedeljka] [u] LT","[prošlog] [utorka] [u] LT","[prošle] [srede] [u] LT","[prošlog] [četvrtka] [u] LT","[prošlog] [petka] [u] LT","[prošle] [subote] [u] LT"];return a[this.day()]},sameElse:"L"},relativeTime:{future:"za %s",past:"pre %s",s:"nekoliko sekundi",m:c.translate,mm:c.translate,h:c.translate,hh:c.translate,d:"dan",dd:c.translate,M:"mesec",MM:c.translate,y:"godinu",yy:c.translate},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("sr","sr",{closeText:"Затвори",prevText:"&#x3C;",nextText:"&#x3E;",currentText:"Данас",monthNames:["Јануар","Фебруар","Март","Април","Мај","Јун","Јул","Август","Септембар","Октобар","Новембар","Децембар"],monthNamesShort:["Јан","Феб","Мар","Апр","Мај","Јун","Јул","Авг","Сеп","Окт","Нов","Дец"],dayNames:["Недеља","Понедељак","Уторак","Среда","Четвртак","Петак","Субота"],dayNamesShort:["Нед","Пон","Уто","Сре","Чет","Пет","Суб"],dayNamesMin:["Не","По","Ут","Ср","Че","Пе","Су"],weekHeader:"Сед",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("sr",{buttonText:{month:"Месец",week:"Недеља",day:"Дан",list:"Планер"},allDayText:"Цео дан",eventLimitText:function(a){return"+ још "+a}})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"th",{months:"มกราคม_กุมภาพันธ์_มีนาคม_เมษายน_พฤษภาคม_มิถุนายน_กรกฎาคม_สิงหาคม_กันยายน_ตุลาคม_พฤศจิกายน_ธันวาคม".split("_"),monthsShort:"มกรา_กุมภา_มีนา_เมษา_พฤษภา_มิถุนา_กรกฎา_สิงหา_กันยา_ตุลา_พฤศจิกา_ธันวา".split("_"),weekdays:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัสบดี_ศุกร์_เสาร์".split("_"),weekdaysShort:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัส_ศุกร์_เสาร์".split("_"),weekdaysMin:"อา._จ._อ._พ._พฤ._ศ._ส.".split("_"),longDateFormat:{LT:"H นาฬิกา m นาที",LTS:"LT s วินาที",L:"YYYY/MM/DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY เวลา LT",LLLL:"วันddddที่ D MMMM YYYY เวลา LT"},meridiemParse:/ก่อนเที่ยง|หลังเที่ยง/,isPM:function(a){return"หลังเที่ยง"===a},meridiem:function(a,b,c){return 12>a?"ก่อนเที่ยง":"หลังเที่ยง"},calendar:{sameDay:"[วันนี้ เวลา] LT",nextDay:"[พรุ่งนี้ เวลา] LT",nextWeek:"dddd[หน้า เวลา] LT",lastDay:"[เมื่อวานนี้ เวลา] LT",lastWeek:"[วัน]dddd[ที่แล้ว เวลา] LT",sameElse:"L"},relativeTime:{future:"อีก %s",past:"%sที่แล้ว",s:"ไม่กี่วินาที",m:"1 นาที",mm:"%d นาที",h:"1 ชั่วโมง",hh:"%d ชั่วโมง",d:"1 วัน",dd:"%d วัน",M:"1 เดือน",MM:"%d เดือน",y:"1 ปี",yy:"%d ปี"}}),a.fullCalendar.datepickerLang("th","th",{closeText:"ปิด",prevText:"&#xAB;&#xA0;ย้อน",nextText:"ถัดไป&#xA0;&#xBB;",currentText:"วันนี้",monthNames:["มกราคม","กุมภาพันธ์","มีนาคม","เมษายน","พฤษภาคม","มิถุนายน","กรกฎาคม","สิงหาคม","กันยายน","ตุลาคม","พฤศจิกายน","ธันวาคม"],monthNamesShort:["ม.ค.","ก.พ.","มี.ค.","เม.ย.","พ.ค.","มิ.ย.","ก.ค.","ส.ค.","ก.ย.","ต.ค.","พ.ย.","ธ.ค."],dayNames:["อาทิตย์","จันทร์","อังคาร","พุธ","พฤหัสบดี","ศุกร์","เสาร์"],dayNamesShort:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],dayNamesMin:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],weekHeader:"Wk",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("th",{buttonText:{month:"เดือน",week:"สัปดาห์",day:"วัน",list:"แผนงาน"},allDayText:"ตลอดวัน",eventLimitText:"เพิ่มเติม"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){var c={1:"'inci",5:"'inci",8:"'inci",70:"'inci",80:"'inci",2:"'nci",7:"'nci",20:"'nci",50:"'nci",3:"'üncü",4:"'üncü",100:"'üncü",6:"'ncı",9:"'uncu",10:"'uncu",30:"'uncu",60:"'ıncı",90:"'ıncı"};(b.defineLocale||b.lang).call(b,"tr",{months:"Ocak_Şubat_Mart_Nisan_Mayıs_Haziran_Temmuz_Ağustos_Eylül_Ekim_Kasım_Aralık".split("_"),monthsShort:"Oca_Şub_Mar_Nis_May_Haz_Tem_Ağu_Eyl_Eki_Kas_Ara".split("_"),weekdays:"Pazar_Pazartesi_Salı_Çarşamba_Perşembe_Cuma_Cumartesi".split("_"),weekdaysShort:"Paz_Pts_Sal_Çar_Per_Cum_Cts".split("_"),weekdaysMin:"Pz_Pt_Sa_Ça_Pe_Cu_Ct".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[bugün saat] LT",nextDay:"[yarın saat] LT",nextWeek:"[haftaya] dddd [saat] LT",lastDay:"[dün] LT",lastWeek:"[geçen hafta] dddd [saat] LT",sameElse:"L"},relativeTime:{future:"%s sonra",past:"%s önce",s:"birkaç saniye",m:"bir dakika",mm:"%d dakika",h:"bir saat",hh:"%d saat",d:"bir gün",dd:"%d gün",M:"bir ay",MM:"%d ay",y:"bir yıl",yy:"%d yıl"},ordinalParse:/\d{1,2}'(inci|nci|üncü|ncı|uncu|ıncı)/,ordinal:function(a){if(0===a)return a+"'ıncı";var b=a%10,d=a%100-b,e=a>=100?100:null;return a+(c[b]||c[d]||c[e])},week:{dow:1,doy:7}}),a.fullCalendar.datepickerLang("tr","tr",{closeText:"kapat",prevText:"&#x3C;geri",nextText:"ileri&#x3e",currentText:"bugün",monthNames:["Ocak","Şubat","Mart","Nisan","Mayıs","Haziran","Temmuz","Ağustos","Eylül","Ekim","Kasım","Aralık"],monthNamesShort:["Oca","Şub","Mar","Nis","May","Haz","Tem","Ağu","Eyl","Eki","Kas","Ara"],dayNames:["Pazar","Pazartesi","Salı","Çarşamba","Perşembe","Cuma","Cumartesi"],dayNamesShort:["Pz","Pt","Sa","Ça","Pe","Cu","Ct"],dayNamesMin:["Pz","Pt","Sa","Ça","Pe","Cu","Ct"],weekHeader:"Hf",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),a.fullCalendar.lang("tr",{buttonText:{next:"ileri",month:"Ay",week:"Hafta",day:"Gün",list:"Ajanda"},allDayText:"Tüm gün",eventLimitText:"daha fazla"})});!function(a){"function"==typeof define&&define.amd?define(["jquery","moment"],a):a(jQuery,moment)}(function(a,b){(b.defineLocale||b.lang).call(b,"zh-cn",{months:"一月_二月_三月_四月_五月_六月_七月_八月_九月_十月_十一月_十二月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"星期日_星期一_星期二_星期三_星期四_星期五_星期六".split("_"),weekdaysShort:"周日_周一_周二_周三_周四_周五_周六".split("_"),weekdaysMin:"日_一_二_三_四_五_六".split("_"),longDateFormat:{LT:"Ah点mm",LTS:"Ah点m分s秒",L:"YYYY-MM-DD",LL:"YYYY年MMMD日",LLL:"YYYY年MMMD日LT",LLLL:"YYYY年MMMD日ddddLT",l:"YYYY-MM-DD",ll:"YYYY年MMMD日",lll:"YYYY年MMMD日LT",llll:"YYYY年MMMD日ddddLT"},meridiemParse:/凌晨|早上|上午|中午|下午|晚上/,meridiemHour:function(a,b){return 12===a&&(a=0),"凌晨"===b||"早上"===b||"上午"===b?a:"下午"===b||"晚上"===b?a+12:a>=11?a:a+12},meridiem:function(a,b,c){var d=100*a+b;return 600>d?"凌晨":900>d?"早上":1130>d?"上午":1230>d?"中午":1800>d?"下午":"晚上"},calendar:{sameDay:function(){return 0===this.minutes()?"[今天]Ah[点整]":"[今天]LT"},nextDay:function(){return 0===this.minutes()?"[明天]Ah[点整]":"[明天]LT"},lastDay:function(){return 0===this.minutes()?"[昨天]Ah[点整]":"[昨天]LT"},nextWeek:function(){var a,c;return a=b().startOf("week"),c=this.unix()-a.unix()>=604800?"[下]":"[本]",0===this.minutes()?c+"dddAh点整":c+"dddAh点mm"},lastWeek:function(){var a,c;return a=b().startOf("week"),c=this.unix()<a.unix()?"[上]":"[本]",0===this.minutes()?c+"dddAh点整":c+"dddAh点mm"},sameElse:"LL"},ordinalParse:/\d{1,2}(日|月|周)/,ordinal:function(a,b){switch(b){case"d":case"D":case"DDD":return a+"日";case"M":return a+"月";case"w":case"W":return a+"周";default:return a}},relativeTime:{future:"%s内",past:"%s前",s:"几秒",m:"1分钟",mm:"%d分钟",h:"1小时",hh:"%d小时",d:"1天",dd:"%d天",M:"1个月",MM:"%d个月",y:"1年",yy:"%d年"},week:{dow:1,doy:4}}),a.fullCalendar.datepickerLang("zh-cn","zh-CN",{closeText:"关闭",prevText:"&#x3C;上月",nextText:"下月&#x3E;",currentText:"今天",monthNames:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthNamesShort:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],dayNames:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayNamesShort:["周日","周一","周二","周三","周四","周五","周六"],dayNamesMin:["日","一","二","三","四","五","六"],weekHeader:"周",dateFormat:"yy-mm-dd",firstDay:1,isRTL:!1,showMonthAfterYear:!0,yearSuffix:"年"}),a.fullCalendar.lang("zh-cn",{buttonText:{month:"月",week:"周",day:"日",list:"日程"},allDayText:"全天",eventLimitText:function(a){return"另外 "+a+" 个"}})});(function(){function u(z){this.app=z;this.router=new x();this.router.addRoute("screenshot-zone",d)}u.prototype.isOpen=function(){return $("#popover-container").size()>0};u.prototype.open=function(A){var z=this;z.app.dropdown.close();$.get(A,function(B){$("body").prepend('<div id="popover-container"><div id="popover-content">'+B+"</div></div>");z.app.refresh();z.router.dispatch(this.app);z.afterOpen()})};u.prototype.close=function(z){if(this.isOpen()){if(z){z.preventDefault()}$("#popover-container").remove()}};u.prototype.onClick=function(B){B.preventDefault();B.stopPropagation();var A=B.currentTarget||B.target;var z=A.getAttribute("href");if(!z){z=A.getAttribute("data-href")}if(z){this.open(z)}};u.prototype.listen=function(){$(document).on("click",".popover",this.onClick.bind(this));$(document).on("click",".close-popover",this.close.bind(this));$(document).on("click","#popover-container",this.close.bind(this));$(document).on("click","#popover-content",function(z){z.stopPropagation()})};u.prototype.afterOpen=function(){var A=this;var z=$("#popover-content .popover-form");if(z){z.on("submit",function(B){B.preventDefault();$.ajax({type:"POST",url:z.attr("action"),data:z.serialize(),success:function(D,E,C){A.afterSubmit(D,C,A)}})})}$(document).on("click",".popover-link",function(B){B.preventDefault();$.ajax({type:"GET",url:$(this).attr("href"),success:function(D,E,C){A.afterSubmit(D,C,A)}})})};u.prototype.afterSubmit=function(B,A,z){var C=A.getResponseHeader("X-Ajax-Redirect");if(C){window.location=C==="self"?window.location.href.split("#")[0]:C}else{$("#popover-content").html(B);$("#popover-content input[autofocus]").focus();z.afterOpen()}};function s(){}s.prototype.listen=function(){var z=this;$(document).on("click",function(){z.close()});$(document).on("click",".dropdown-menu",function(D){D.preventDefault();D.stopImmediatePropagation();z.close();var B=$(this).next("ul");var E=$(this).offset();$("body").append(jQuery("<div>",{id:"dropdown"}));B.clone().appendTo("#dropdown");var F=$("#dropdown ul");F.addClass("dropdown-submenu-open");var C=F.outerHeight();var A=F.outerWidth();if(E.top+C-$(window).scrollTop()<$(window).height()||$(window).scrollTop()+E.top<C){F.css("top",E.top+$(this).height())}else{F.css("top",E.top-C-5)}if(E.left+A>$(window).width()){F.css("left",E.left-A+$(this).outerWidth())}else{F.css("left",E.left)}});$(document).on("click",".dropdown-submenu-open li",function(A){if($(A.target).is("li")){$(this).find("a:visible")[0].click()}});$("textarea[data-mention-search-url]").textcomplete([{match:/(^|\s)@(\w*)$/,search:function(B,C){var A=$("textarea[data-mention-search-url]").data("mention-search-url");$.getJSON(A,{q:B}).done(function(D){C(D)}).fail(function(){C([])})},replace:function(A){return"$1@"+A+" "},cache:true}],{className:"textarea-dropdown"})};s.prototype.close=function(){$("#dropdown").remove()};function r(z){this.app=z}r.prototype.listen=function(){var z=this;$(".tooltip").tooltip({track:false,show:false,hide:false,position:{my:"left-20 top",at:"center bottom+9",using:function(A,B){$(this).css(A);var C=B.target.left+B.target.width/2-B.element.left-20;$("<div>").addClass("tooltip-arrow").addClass(B.vertical).addClass(C<1?"align-left":"align-right").appendTo(this)}},content:function(){var C=this;var A=$(this).attr("data-href");if(!A){return'<div class="markdown">'+$(this).attr("title")+"</div>"}$.get(A,function B(F){var E=$(".ui-tooltip:visible");$(".ui-tooltip-content:visible").html(F);E.css({top:"",left:""});E.children(".tooltip-arrow").remove();var D=$(C).tooltip("option","position");D.of=$(C);E.position(D)});return'<i class="fa fa-spinner fa-spin"></i>'}}).on("mouseenter",function(){var A=this;$(this).tooltip("open");$(".ui-tooltip").on("mouseleave",function(){$(A).tooltip("close")})}).on("mouseleave focusout",function(A){A.stopImmediatePropagation();var B=this;setTimeout(function(){if(!$(".ui-tooltip:hover").length){$(B).tooltip("close")}},100)})};function m(){}m.prototype.showPreview=function(D){D.preventDefault();var A=$(".write-area");var C=$(".preview-area");var z=$("textarea");$("#markdown-write").parent().removeClass("form-tab-selected");$("#markdown-preview").parent().addClass("form-tab-selected");var B=$.ajax({url:$("body").data("markdown-preview-url"),contentType:"application/json",type:"POST",processData:false,dataType:"html",data:JSON.stringify({text:z.val()})});B.done(function(E){C.find(".markdown").html(E);C.css("height",z.css("height"));C.css("width",z.css("width"));A.hide();C.show()})};m.prototype.showWriter=function(z){z.preventDefault();$("#markdown-write").parent().addClass("form-tab-selected");$("#markdown-preview").parent().removeClass("form-tab-selected");$(".write-area").show();$(".preview-area").hide()};m.prototype.listen=function(){$(document).on("click","#markdown-preview",this.showPreview.bind(this));$(document).on("click","#markdown-write",this.showWriter.bind(this))};function f(z){this.app=z;this.keyboardShortcuts()}f.prototype.focus=function(){$(document).on("focus","#form-search",function(){if($("#form-search")[0].setSelectionRange){$("#form-search")[0].setSelectionRange($("#form-search").val().length,$("#form-search").val().length)}})};f.prototype.listen=function(){var z=this;$(document).on("click",".filter-helper",function(C){C.preventDefault();var B=$(this).data("filter");var A=$(this).data("append-filter");if(A){B=$("#form-search").val()+" "+A}$("#form-search").val(B);if($("#board").length){z.app.board.reloadFilters(B)}else{$("form.search").submit()}})};f.prototype.keyboardShortcuts=function(){var z=this;Mousetrap.bind("v o",function(B){var A=$(".view-overview");if(A.length){window.location=A.attr("href")}});Mousetrap.bind("v b",function(B){var A=$(".view-board");if(A.length){window.location=A.attr("href")}});Mousetrap.bind("v c",function(B){var A=$(".view-calendar");if(A.length){window.location=A.attr("href")}});Mousetrap.bind("v l",function(B){var A=$(".view-listing");if(A.length){window.location=A.attr("href")}});Mousetrap.bind("v g",function(B){var A=$(".view-gantt");if(A.length){window.location=A.attr("href")}});Mousetrap.bind("f",function(B){B.preventDefault();var A=document.getElementById("form-search");if(A){A.focus()}});Mousetrap.bind("r",function(B){B.preventDefault();var A=$(".filter-reset").data("filter");$("#form-search").val(A);if($("#board").length){z.app.board.reloadFilters(A)}else{$("form.search").submit()}})};function n(){this.board=new k(this);this.markdown=new m();this.search=new f(this);this.swimlane=new g(this);this.dropdown=new s();this.tooltip=new r(this);this.popover=new u(this);this.task=new a(this);this.project=new o();this.subtask=new e(this);this.column=new l(this);this.file=new w(this);this.keyboardShortcuts();this.chosen();this.poll();$(".alert-fade-out").delay(4000).fadeOut(800,function(){$(this).remove()})}n.prototype.listen=function(){this.project.listen();this.popover.listen();this.markdown.listen();this.tooltip.listen();this.dropdown.listen();this.search.listen();this.task.listen();this.swimlane.listen();this.subtask.listen();this.column.listen();this.file.listen();this.search.focus();this.autoComplete();this.datePicker();this.focus()};n.prototype.refresh=function(){$(document).off();this.listen()};n.prototype.focus=function(){$("[autofocus]").each(function(z,A){$(this).focus()});$(document).on("focus",".auto-select",function(){$(this).select()});$(document).on("mouseup",".auto-select",function(z){z.preventDefault()})};n.prototype.poll=function(){window.setInterval(this.checkSession,60000)};n.prototype.keyboardShortcuts=function(){var z=this;Mousetrap.bindGlobal("mod+enter",function(){$("form").submit()});Mousetrap.bind("b",function(A){A.preventDefault();$("#board-selector").trigger("chosen:open")});Mousetrap.bindGlobal("esc",function(){z.popover.close();z.dropdown.close()})};n.prototype.checkSession=function(){if(!$(".form-login").length){$.ajax({cache:false,url:$("body").data("status-url"),statusCode:{401:function(){window.location=$("body").data("login-url")}}})}};n.prototype.datePicker=function(){$.datepicker.setDefaults($.datepicker.regional[$("body").data("js-lang")]);$(".form-date").datepicker({showOtherMonths:true,selectOtherMonths:true,dateFormat:"yy-mm-dd",constrainInput:false});$(".form-datetime").datetimepicker({controlType:"select",oneLine:true,dateFormat:"yy-mm-dd",constrainInput:false})};n.prototype.autoComplete=function(){$(".autocomplete").each(function(){var A=$(this);var B=A.data("dst-field");var z=A.data("dst-extra-field");if($("#form-"+B).val()==""){A.parent().find("input[type=submit]").attr("disabled","disabled")}A.autocomplete({source:A.data("search-url"),minLength:1,select:function(C,D){$("input[name="+B+"]").val(D.item.id);if(z){$("input[name="+z+"]").val(D.item[z])}A.parent().find("input[type=submit]").removeAttr("disabled")}})})};n.prototype.chosen=function(){$(".chosen-select").each(function(){var z=$(this).data("search-threshold");if(z===undefined){z=10}$(this).chosen({width:"180px",no_results_text:$(this).data("notfound"),disable_search_threshold:z})});$(".select-auto-redirect").change(function(){var z=new RegExp($(this).data("redirect-regex"),"g");window.location=$(this).data("redirect-url").replace(z,$(this).val())})};n.prototype.showLoadingIcon=function(){$("body").append('<span id="app-loading-icon">&nbsp;<i class="fa fa-spinner fa-spin"></i></span>')};n.prototype.hideLoadingIcon=function(){$("#app-loading-icon").remove()};n.prototype.isVisible=function(){var z="";if(typeof document.hidden!=="undefined"){z="visibilityState"}else{if(typeof document.mozHidden!=="undefined"){z="mozVisibilityState"}else{if(typeof document.msHidden!=="undefined"){z="msVisibilityState"}else{if(typeof document.webkitHidden!=="undefined"){z="webkitVisibilityState"}}}}if(z!=""){return document[z]=="visible"}return true};n.prototype.formatDuration=function(z){if(z>=86400){return Math.round(z/86400)+"d"}else{if(z>=3600){return Math.round(z/3600)+"h"}else{if(z>=60){return Math.round(z/60)+"m"}}}return z+"s"};function d(){this.pasteCatcher=null}d.prototype.execute=function(){this.initialize()};d.prototype.initialize=function(){this.destroy();if(!window.Clipboard){this.pasteCatcher=document.createElement("div");this.pasteCatcher.id="screenshot-pastezone";this.pasteCatcher.contentEditable="true";this.pasteCatcher.style.opacity=0;this.pasteCatcher.style.position="fixed";this.pasteCatcher.style.top=0;this.pasteCatcher.style.right=0;this.pasteCatcher.style.width=0;document.body.insertBefore(this.pasteCatcher,document.body.firstChild);this.pasteCatcher.focus();document.addEventListener("click",this.setFocus.bind(this));document.getElementById("screenshot-zone").addEventListener("click",this.setFocus.bind(this))}window.addEventListener("paste",this.pasteHandler.bind(this))};d.prototype.destroy=function(){if(this.pasteCatcher!=null){document.body.removeChild(this.pasteCatcher)}else{if(document.getElementById("screenshot-pastezone")){document.body.removeChild(document.getElementById("screenshot-pastezone"))}}document.removeEventListener("click",this.setFocus.bind(this));this.pasteCatcher=null};d.prototype.setFocus=function(){if(this.pasteCatcher!==null){this.pasteCatcher.focus()}};d.prototype.pasteHandler=function(E){if(E.clipboardData&&E.clipboardData.items){var C=E.clipboardData.items;if(C){for(var D=0;D<C.length;D++){if(C[D].type.indexOf("image")!==-1){var B=C[D].getAsFile();var z=new FileReader();var A=this;z.onload=function(F){A.createImage(F.target.result)};z.readAsDataURL(B)}}}}else{setTimeout(this.checkInput.bind(this),100)}};d.prototype.checkInput=function(){var z=this.pasteCatcher.childNodes[0];if(z){if(z.tagName==="IMG"){this.createImage(z.src)}}this.pasteCatcher.innerHTML=""};d.prototype.createImage=function(B){var A=new Image();A.src=B;A.onload=function(){var C=B.split("base64,");var D=C[1];$("input[name=screenshot]").val(D)};var z=document.getElementById("screenshot-zone");z.innerHTML="";z.className="screenshot-pasted";z.appendChild(A);this.destroy();this.initialize()};function w(z){this.app=z;this.files=[];this.currentFile=0}w.prototype.listen=function(){var z=document.getElementById("file-dropzone");var A=this;if(z){z.ondragover=z.ondragenter=function(B){B.stopPropagation();B.preventDefault()};z.ondrop=function(B){B.stopPropagation();B.preventDefault();A.files=B.dataTransfer.files;A.show();$("#file-error-max-size").hide()};$(document).on("click","#file-browser",function(B){B.preventDefault();$("#file-form-element").get(0).click()});$(document).on("click","#file-upload-button",function(B){B.preventDefault();A.currentFile=0;A.checkFiles()});$("#file-form-element").change(function(){A.files=document.getElementById("file-form-element").files;A.show();$("#file-error-max-size").hide()})}};w.prototype.show=function(){$("#file-list").remove();if(this.files.length>0){$("#file-upload-button").prop("disabled",false);$("#file-dropzone-inner").hide();var D=jQuery("<ul>",{id:"file-list"});for(var C=0;C<this.files.length;C++){var A=jQuery("<span>",{id:"file-percentage-"+C}).append("(0%)");var B=jQuery("<progress>",{id:"file-progress-"+C,value:0});var z=jQuery("<li>",{id:"file-label-"+C}).append(B).append("&nbsp;").append(this.files[C].name).append("&nbsp;").append(A);D.append(z)}$("#file-dropzone").append(D)}else{$("#file-dropzone-inner").show()}};w.prototype.checkFiles=function(){var z=parseInt($("#file-dropzone").data("max-size"));for(var A=0;A<this.files.length;A++){if(this.files[A].size>z){$("#file-error-max-size").show();$("#file-label-"+A).addClass("file-error");$("#file-upload-button").prop("disabled",true);return}}this.uploadFiles()};w.prototype.uploadFiles=function(){if(this.files.length>0){this.uploadFile(this.files[this.currentFile])}};w.prototype.uploadFile=function(C){var z=document.getElementById("file-dropzone");var A=z.dataset.url;var D=new XMLHttpRequest();var B=new FormData();D.upload.addEventListener("progress",this.updateProgress.bind(this));D.upload.addEventListener("load",this.transferComplete.bind(this));D.open("POST",A,true);B.append("files[]",C);D.send(B)};w.prototype.updateProgress=function(z){if(z.lengthComputable){$("#file-progress-"+this.currentFile).val(z.loaded/z.total);$("#file-percentage-"+this.currentFile).text("("+Math.floor((z.loaded/z.total)*100)+"%)")}};w.prototype.transferComplete=function(){this.currentFile++;if(this.currentFile<this.files.length){this.uploadFile(this.files[this.currentFile])}else{$("#file-upload-button").prop("disabled",true);$("#file-upload-button").parent().hide();$("#file-done").show()}};function j(){}j.prototype.execute=function(){var z=$("#calendar");z.fullCalendar({lang:$("body").data("js-lang"),editable:true,eventLimit:true,defaultView:"month",header:{left:"prev,next today",center:"title",right:"month,agendaWeek,agendaDay"},eventDrop:function(A){$.ajax({cache:false,url:z.data("save-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({task_id:A.id,date_due:A.start.format()})})},viewRender:function(){var A=z.data("check-url");var C={start:z.fullCalendar("getView").start.format(),end:z.fullCalendar("getView").end.format()};for(var B in C){A+="&"+B+"="+C[B]}$.getJSON(A,function(D){z.fullCalendar("removeEvents");z.fullCalendar("addEventSource",D);z.fullCalendar("rerenderEvents")})}})};function k(z){this.app=z;this.checkInterval=null;this.savingInProgress=false}k.prototype.execute=function(){this.app.swimlane.refresh();this.restoreColumnViewMode();this.compactView();this.poll();this.keyboardShortcuts();this.listen();this.dragAndDrop();$(window).on("load",this.columnScrolling);$(window).resize(this.columnScrolling)};k.prototype.poll=function(){var z=parseInt($("#board").attr("data-check-interval"));if(z>0){this.checkInterval=window.setInterval(this.check.bind(this),z*1000)}};k.prototype.reloadFilters=function(z){this.app.showLoadingIcon();$.ajax({cache:false,url:$("#board").data("reload-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({search:z}),success:this.refresh.bind(this),error:this.app.hideLoadingIcon.bind(this)})};k.prototype.check=function(){if(this.app.isVisible()&&!this.savingInProgress){var z=this;this.app.showLoadingIcon();$.ajax({cache:false,url:$("#board").data("check-url"),statusCode:{200:function(A){z.refresh(A)},304:function(){z.app.hideLoadingIcon()}}})}};k.prototype.save=function(C,D,z,B){var A=this;this.app.showLoadingIcon();this.savingInProgress=true;$.ajax({cache:false,url:$("#board").data("save-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({task_id:C,column_id:D,swimlane_id:B,position:z}),success:function(E){A.refresh(E);this.savingInProgress=false},error:function(){A.app.hideLoadingIcon();this.savingInProgress=false}})};k.prototype.refresh=function(z){$("#board-container").replaceWith(z);this.app.refresh();this.app.swimlane.refresh();this.app.hideLoadingIcon();this.listen();this.dragAndDrop();this.compactView();this.restoreColumnViewMode();this.columnScrolling()};k.prototype.dragAndDrop=function(){var z=this;var A={forcePlaceholderSize:true,tolerance:"pointer",connectWith:".board-task-list",placeholder:"draggable-placeholder",items:".draggable-item",stop:function(C,J){var E=J.item;var I=E.attr("data-task-id");var K=E.attr("data-position");var H=E.attr("data-column-id");var G=E.attr("data-swimlane-id");var D=E.parent().attr("data-column-id");var B=E.parent().attr("data-swimlane-id");var F=E.index()+1;E.removeClass("draggable-item-selected");if(D!=H||B!=G||F!=K){z.changeTaskState(I);z.save(I,D,F,B)}},start:function(B,C){C.item.addClass("draggable-item-selected");C.placeholder.height(C.item.height())}};if($.support.touch){$(".task-board-sort-handle").css("display","inline");A.handle=".task-board-sort-handle"}$(".board-task-list").sortable(A)};k.prototype.changeTaskState=function(A){var z=$("div[data-task-id="+A+"]");z.addClass("task-board-saving-state");z.find(".task-board-saving-icon").show()};k.prototype.listen=function(){var z=this;$(document).on("click",".task-board",function(A){if(A.target.tagName!="A"){window.location=$(this).data("task-url")}});$(document).on("click",".filter-toggle-scrolling",function(A){A.preventDefault();z.toggleCompactView()});$(document).on("click",".filter-toggle-height",function(A){A.preventDefault();z.toggleColumnScrolling()});$(document).on("click",".board-toggle-column-view",function(){z.toggleColumnViewMode($(this).data("column-id"))})};k.prototype.toggleColumnScrolling=function(){var z=localStorage.getItem("column_scroll");if(z==undefined){z=1}localStorage.setItem("column_scroll",z==0?1:0);this.columnScrolling()};k.prototype.columnScrolling=function(){if(localStorage.getItem("column_scroll")==0){var z=80;$(".filter-max-height").show();$(".filter-min-height").hide();$(".board-rotation-wrapper").css("min-height","");$(".board-task-list").each(function(){var A=$(this).height();if(A>z){z=A}});$(".board-task-list").css("min-height",z);$(".board-task-list").css("height","")}else{$(".filter-max-height").hide();$(".filter-min-height").show();if($(".board-swimlane").length>1){$(".board-task-list").each(function(){if($(this).height()>500){$(this).css("height",500)}else{$(this).css("min-height",320);$(".board-rotation-wrapper").css("min-height",320)}})}else{var z=$(window).height()-170;$(".board-task-list").css("height",z);$(".board-rotation-wrapper").css("min-height",z)}}};k.prototype.toggleCompactView=function(){var z=localStorage.getItem("horizontal_scroll")||1;localStorage.setItem("horizontal_scroll",z==0?1:0);this.compactView()};k.prototype.compactView=function(){if(localStorage.getItem("horizontal_scroll")==0){$(".filter-wide").show();$(".filter-compact").hide();$("#board-container").addClass("board-container-compact");$("#board th:not(.board-column-header-collapsed)").addClass("board-column-compact")}else{$(".filter-wide").hide();$(".filter-compact").show();$("#board-container").removeClass("board-container-compact");$("#board th").removeClass("board-column-compact")}};k.prototype.toggleCollapsedMode=function(){var z=this;this.app.showLoadingIcon();$.ajax({cache:false,url:$('.filter-display-mode:not([style="display: none;"]) a').attr("href"),success:function(A){$(".filter-display-mode").toggle();z.refresh(A)}})};k.prototype.restoreColumnViewMode=function(){var z=this;$(".board-column-header").each(function(){var A=$(this).data("column-id");if(localStorage.getItem("hidden_column_"+A)){z.hideColumn(A)}})};k.prototype.toggleColumnViewMode=function(z){if(localStorage.getItem("hidden_column_"+z)){this.showColumn(z)}else{this.hideColumn(z)}};k.prototype.hideColumn=function(z){$(".board-column-"+z+" .board-column-expanded").hide();$(".board-column-"+z+" .board-column-collapsed").show();$(".board-column-header-"+z+" .board-column-expanded").hide();$(".board-column-header-"+z+" .board-column-collapsed").show();$(".board-column-header-"+z).each(function(){$(this).removeClass("board-column-compact");$(this).addClass("board-column-header-collapsed")});$(".board-column-"+z).each(function(){$(this).addClass("board-column-task-collapsed")});$(".board-column-"+z+" .board-rotation").each(function(){$(this).css("width",$(".board-column-"+z+"").height())});localStorage.setItem("hidden_column_"+z,1)};k.prototype.showColumn=function(z){$(".board-column-"+z+" .board-column-expanded").show();$(".board-column-"+z+" .board-column-collapsed").hide();$(".board-column-header-"+z+" .board-column-expanded").show();$(".board-column-header-"+z+" .board-column-collapsed").hide();$(".board-column-header-"+z).removeClass("board-column-header-collapsed");$(".board-column-"+z).removeClass("board-column-task-collapsed");if(localStorage.getItem("horizontal_scroll")==0){$(".board-column-header-"+z).addClass("board-column-compact")}localStorage.removeItem("hidden_column_"+z)};k.prototype.keyboardShortcuts=function(){var z=this;Mousetrap.bind("c",function(){z.toggleCompactView()});Mousetrap.bind("s",function(){z.toggleCollapsedMode()});Mousetrap.bind("n",function(){z.app.popover.open($("#board").data("task-creation-url"))})};function l(z){this.app=z}l.prototype.listen=function(){this.dragAndDrop()};l.prototype.dragAndDrop=function(){var z=this;$(".draggable-row-handle").mouseenter(function(){$(this).parent().parent().addClass("draggable-item-hover")}).mouseleave(function(){$(this).parent().parent().removeClass("draggable-item-hover")});$(".columns-table tbody").sortable({forcePlaceholderSize:true,handle:"td:first i",helper:function(B,A){A.children().each(function(){$(this).width($(this).width())});return A},stop:function(B,C){var A=C.item;A.removeClass("draggable-item-selected");z.savePosition(A.data("column-id"),A.index()+1)},start:function(A,B){B.item.addClass("draggable-item-selected")}}).disableSelection()};l.prototype.savePosition=function(C,z){var B=$(".columns-table").data("save-position-url");var A=this;this.app.showLoadingIcon();$.ajax({cache:false,url:B,contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({column_id:C,position:z}),complete:function(){A.app.hideLoadingIcon()}})};function g(z){this.app=z}g.prototype.getStorageKey=function(){return"hidden_swimlanes_"+$("#board").data("project-id")};g.prototype.expand=function(A){var B=this.getAllCollapsed();var z=B.indexOf(A);if(z>-1){B.splice(z,1)}localStorage.setItem(this.getStorageKey(),JSON.stringify(B));$(".board-swimlane-columns-"+A).css("display","table-row");$(".board-swimlane-tasks-"+A).css("display","table-row");$(".hide-icon-swimlane-"+A).css("display","inline");$(".show-icon-swimlane-"+A).css("display","none")};g.prototype.collapse=function(z){var A=this.getAllCollapsed();if(A.indexOf(z)<0){A.push(z);localStorage.setItem(this.getStorageKey(),JSON.stringify(A))}$(".board-swimlane-columns-"+z+":not(:first-child)").css("display","none");$(".board-swimlane-tasks-"+z).css("display","none");$(".hide-icon-swimlane-"+z).css("display","none");$(".show-icon-swimlane-"+z).css("display","inline")};g.prototype.isCollapsed=function(z){return this.getAllCollapsed().indexOf(z)>-1};g.prototype.getAllCollapsed=function(){return JSON.parse(localStorage.getItem(this.getStorageKey()))||[]};g.prototype.refresh=function(){var A=this.getAllCollapsed();for(var z=0;z<A.length;z++){this.collapse(A[z])}};g.prototype.listen=function(){var z=this;z.dragAndDrop();$(document).on("click",".board-swimlane-toggle",function(B){B.preventDefault();var A=$(this).data("swimlane-id");if(z.isCollapsed(A)){z.expand(A)}else{z.collapse(A)}})};g.prototype.dragAndDrop=function(){var z=this;$(".draggable-row-handle").mouseenter(function(){$(this).parent().parent().addClass("draggable-item-hover")}).mouseleave(function(){$(this).parent().parent().removeClass("draggable-item-hover")});$(".swimlanes-table tbody").sortable({forcePlaceholderSize:true,handle:"td:first i",helper:function(B,A){A.children().each(function(){$(this).width($(this).width())});return A},stop:function(A,C){var B=C.item;B.removeClass("draggable-item-selected");z.savePosition(B.data("swimlane-id"),B.index()+1)},start:function(A,B){B.item.addClass("draggable-item-selected")}}).disableSelection()};g.prototype.savePosition=function(C,z){var B=$(".swimlanes-table").data("save-position-url");var A=this;this.app.showLoadingIcon();$.ajax({cache:false,url:B,contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({swimlane_id:C,position:z}),complete:function(){A.app.hideLoadingIcon()}})};function b(z){this.app=z;this.data=[];this.options={container:"#gantt-chart",showWeekends:true,allowMoves:true,allowResizes:true,cellWidth:21,cellHeight:31,slideWidth:1000,vHeaderWidth:200}}b.prototype.saveRecord=function(z){this.app.showLoadingIcon();$.ajax({cache:false,url:$(this.options.container).data("save-url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify(z),complete:this.app.hideLoadingIcon.bind(this)})};b.prototype.execute=function(){this.data=this.prepareData($(this.options.container).data("records"));var C=Math.floor((this.options.slideWidth/this.options.cellWidth)+5);var B=this.getDateRange(C);var z=B[0];var E=B[1];var A=$(this.options.container);var D=jQuery("<div>",{"class":"ganttview"});D.append(this.renderVerticalHeader());D.append(this.renderSlider(z,E));A.append(D);jQuery("div.ganttview-grid-row div.ganttview-grid-row-cell:last-child",A).addClass("last");jQuery("div.ganttview-hzheader-days div.ganttview-hzheader-day:last-child",A).addClass("last");jQuery("div.ganttview-hzheader-months div.ganttview-hzheader-month:last-child",A).addClass("last");if(!$(this.options.container).data("readonly")){this.listenForBlockResize(z);this.listenForBlockMove(z)}else{this.options.allowResizes=false;this.options.allowMoves=false}};b.prototype.renderVerticalHeader=function(){var D=jQuery("<div>",{"class":"ganttview-vtheader"});var A=jQuery("<div>",{"class":"ganttview-vtheader-item"});var C=jQuery("<div>",{"class":"ganttview-vtheader-series"});for(var z=0;z<this.data.length;z++){var B=jQuery("<span>").append(jQuery("<i>",{"class":"fa fa-info-circle tooltip",title:this.getVerticalHeaderTooltip(this.data[z])})).append("&nbsp;");if(this.data[z].type=="task"){B.append(jQuery("<a>",{href:this.data[z].link,target:"_blank",title:this.data[z].title}).append(this.data[z].title))}else{B.append(jQuery("<a>",{href:this.data[z].board_link,target:"_blank",title:$(this.options.container).data("label-board-link")}).append('<i class="fa fa-th"></i>')).append("&nbsp;").append(jQuery("<a>",{href:this.data[z].gantt_link,target:"_blank",title:$(this.options.container).data("label-gantt-link")}).append('<i class="fa fa-sliders"></i>')).append("&nbsp;").append(jQuery("<a>",{href:this.data[z].link,target:"_blank"}).append(this.data[z].title))}C.append(jQuery("<div>",{"class":"ganttview-vtheader-series-name"}).append(B))}A.append(C);D.append(A);return D};b.prototype.renderSlider=function(A,C){var z=jQuery("<div>",{"class":"ganttview-slide-container"});var B=this.getDates(A,C);z.append(this.renderHorizontalHeader(B));z.append(this.renderGrid(B));z.append(this.addBlockContainers());this.addBlocks(z,A);return z};b.prototype.renderHorizontalHeader=function(z){var F=jQuery("<div>",{"class":"ganttview-hzheader"});var D=jQuery("<div>",{"class":"ganttview-hzheader-months"});var C=jQuery("<div>",{"class":"ganttview-hzheader-days"});var B=0;for(var G in z){for(var A in z[G]){var H=z[G][A].length*this.options.cellWidth;B=B+H;D.append(jQuery("<div>",{"class":"ganttview-hzheader-month",css:{width:(H-1)+"px"}}).append($.datepicker.regional[$("body").data("js-lang")].monthNames[A]+" "+G));for(var E in z[G][A]){C.append(jQuery("<div>",{"class":"ganttview-hzheader-day"}).append(z[G][A][E].getDate()))}}}D.css("width",B+"px");C.css("width",B+"px");F.append(D).append(C);return F};b.prototype.renderGrid=function(z){var H=jQuery("<div>",{"class":"ganttview-grid"});var C=jQuery("<div>",{"class":"ganttview-grid-row"});for(var F in z){for(var A in z[F]){for(var E in z[F][A]){var B=jQuery("<div>",{"class":"ganttview-grid-row-cell"});if(this.options.showWeekends&&this.isWeekend(z[F][A][E])){B.addClass("ganttview-weekend")}C.append(B)}}}var G=jQuery("div.ganttview-grid-row-cell",C).length*this.options.cellWidth;C.css("width",G+"px");H.css("width",G+"px");for(var D=0;D<this.data.length;D++){H.append(C.clone())}return H};b.prototype.addBlockContainers=function(){var A=jQuery("<div>",{"class":"ganttview-blocks"});for(var z=0;z<this.data.length;z++){A.append(jQuery("<div>",{"class":"ganttview-block-container"}))}return A};b.prototype.addBlocks=function(A,z){var H=jQuery("div.ganttview-blocks div.ganttview-block-container",A);var B=0;for(var E=0;E<this.data.length;E++){var F=this.data[E];var I=this.daysBetween(F.start,F.end)+1;var D=this.daysBetween(z,F.start);var G=jQuery("<div>",{"class":"ganttview-block-text"});var C=jQuery("<div>",{"class":"ganttview-block tooltip"+(this.options.allowMoves?" ganttview-block-movable":""),title:this.getBarTooltip(F),css:{width:((I*this.options.cellWidth)-9)+"px","margin-left":(D*this.options.cellWidth)+"px"}}).append(G);if(I>=2){G.append(F.progress)}C.data("record",F);this.setBarColor(C,F);if(F.progress!="0%"){C.append(jQuery("<div>",{css:{"z-index":0,position:"absolute",top:0,bottom:0,"background-color":F.color.border,width:F.progress,opacity:0.4}}))}jQuery(H[B]).append(C);B=B+1}};b.prototype.getVerticalHeaderTooltip=function(A){var F="";if(A.type=="task"){F="<strong>"+A.column_title+"</strong> ("+A.progress+")<br/>"+A.title}else{var C=["managers","members"];for(var B in C){var D=C[B];if(!jQuery.isEmptyObject(A.users[D])){var E=jQuery("<ul>");for(var z in A.users[D]){E.append(jQuery("<li>").append(A.users[D][z]))}F+="<p><strong>"+$(this.options.container).data("label-"+D)+"</strong></p>"+E[0].outerHTML}}}return F};b.prototype.getBarTooltip=function(z){var A="";if(z.not_defined){A=$(this.options.container).data("label-not-defined")}else{if(z.type=="task"){A="<strong>"+z.progress+"</strong><br/>"+$(this.options.container).data("label-assignee")+" "+(z.assignee?z.assignee:"")+"<br/>"}A+=$(this.options.container).data("label-start-date")+" "+$.datepicker.formatDate("yy-mm-dd",z.start)+"<br/>";A+=$(this.options.container).data("label-end-date")+" "+$.datepicker.formatDate("yy-mm-dd",z.end)}return A};b.prototype.setBarColor=function(A,z){if(z.not_defined){A.addClass("ganttview-block-not-defined")}else{A.css("background-color",z.color.background);A.css("border-color",z.color.border)}};b.prototype.listenForBlockResize=function(z){var A=this;jQuery("div.ganttview-block",this.options.container).resizable({grid:this.options.cellWidth,handles:"e,w",delay:300,stop:function(){var B=jQuery(this);A.updateDataAndPosition(B,z);A.saveRecord(B.data("record"))}})};b.prototype.listenForBlockMove=function(z){var A=this;jQuery("div.ganttview-block",this.options.container).draggable({axis:"x",delay:300,grid:[this.options.cellWidth,this.options.cellWidth],stop:function(){var B=jQuery(this);A.updateDataAndPosition(B,z);A.saveRecord(B.data("record"))}})};b.prototype.updateDataAndPosition=function(E,C){var z=jQuery("div.ganttview-slide-container",this.options.container);var I=z.scrollLeft();var F=E.offset().left-z.offset().left-1+I;var H=E.data("record");H.not_defined=false;this.setBarColor(E,H);var B=Math.round(F/this.options.cellWidth);var G=this.addDays(this.cloneDate(C),B);H.start=G;var A=E.outerWidth();var D=Math.round(A/this.options.cellWidth)-1;H.end=this.addDays(this.cloneDate(G),D);if(H.type==="task"&&D>0){jQuery("div.ganttview-block-text",E).text(H.progress)}E.attr("title",this.getBarTooltip(H));E.data("record",H);E.css("top","").css("left","").css("position","relative").css("margin-left",F+"px")};b.prototype.getDates=function(D,z){var C=[];C[D.getFullYear()]=[];C[D.getFullYear()][D.getMonth()]=[D];var B=D;while(this.compareDate(B,z)==-1){var A=this.addDays(this.cloneDate(B),1);if(!C[A.getFullYear()]){C[A.getFullYear()]=[]}if(!C[A.getFullYear()][A.getMonth()]){C[A.getFullYear()][A.getMonth()]=[]}C[A.getFullYear()][A.getMonth()].push(A);B=A}return C};b.prototype.prepareData=function(B){for(var A=0;A<B.length;A++){var C=new Date(B[A].start[0],B[A].start[1]-1,B[A].start[2],0,0,0,0);B[A].start=C;var z=new Date(B[A].end[0],B[A].end[1]-1,B[A].end[2],0,0,0,0);B[A].end=z}return B};b.prototype.getDateRange=function(B){var E=new Date();var A=new Date();for(var C=0;C<this.data.length;C++){var D=new Date();D.setTime(Date.parse(this.data[C].start));var z=new Date();z.setTime(Date.parse(this.data[C].end));if(C==0){E=D;A=z}if(this.compareDate(E,D)==1){E=D}if(this.compareDate(A,z)==-1){A=z}}if(this.daysBetween(E,A)<B){A=this.addDays(this.cloneDate(E),B)}E.setDate(E.getDate()-1);return[E,A]};b.prototype.daysBetween=function(C,z){if(!C||!z){return 0}var B=0,A=this.cloneDate(C);while(this.compareDate(A,z)==-1){B=B+1;this.addDays(A,1)}return B};b.prototype.isWeekend=function(z){return z.getDay()%6==0};b.prototype.cloneDate=function(z){return new Date(z.getTime())};b.prototype.addDays=function(z,A){z.setDate(z.getDate()+A*1);return z};b.prototype.compareDate=function(A,z){if(isNaN(A)||isNaN(z)){throw new Error(A+" - "+z)}else{if(A instanceof Date&&z instanceof Date){return(A<z)?-1:(A>z)?1:0}else{throw new TypeError(A+" - "+z)}}};function a(z){this.app=z}a.prototype.listen=function(){var z=this;var A=0;$(document).on("click",".color-square",function(){$(".color-square-selected").removeClass("color-square-selected");$(this).addClass("color-square-selected");$("#form-color_id").val($(this).data("color-id"))});$(document).on("click",".assign-me",function(D){D.preventDefault();var B=$(this).data("current-id");var C="#"+$(this).data("target-id");if($(C+" option[value="+B+"]").length){$(C).val(B)}});$(document).on("change","select.task-reload-project-destination",function(){if(A>0){$(this).val(A)}else{A=$(this).val();var B=$(this).data("redirect").replace(/PROJECT_ID/g,A);$(".loading-icon").show();$.ajax({type:"GET",url:B,success:function(D,E,C){A=0;$(".loading-icon").hide();z.app.popover.afterSubmit(D,C,z.app.popover)}})}})};function o(){}o.prototype.listen=function(){$(".project-change-role").on("change",function(){$.ajax({cache:false,url:$(this).data("url"),contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({id:$(this).data("id"),role:$(this).val()})})});$("#project-creation-form #form-src_project_id").on("change",function(){var z=$(this).val();if(z==0){$(".project-creation-options").hide()}else{$(".project-creation-options").show()}})};function e(z){this.app=z}e.prototype.listen=function(){var z=this;this.dragAndDrop();$(document).on("click",".subtask-toggle-status",function(B){B.preventDefault();var A=$(this);$.ajax({cache:false,url:A.attr("href"),success:function(C){if(A.hasClass("subtask-refresh-table")){$(".subtasks-table").replaceWith(C)}else{A.replaceWith(C)}z.dragAndDrop()}})});$(document).on("click",".subtask-toggle-timer",function(B){B.preventDefault();var A=$(this);$.ajax({cache:false,url:A.attr("href"),success:function(C){$(".subtasks-table").replaceWith(C);z.dragAndDrop()}})})};e.prototype.dragAndDrop=function(){var z=this;$(".draggable-row-handle").mouseenter(function(){$(this).parent().parent().addClass("draggable-item-hover")}).mouseleave(function(){$(this).parent().parent().removeClass("draggable-item-hover")});$(".subtasks-table tbody").sortable({forcePlaceholderSize:true,handle:"td:first i",helper:function(B,A){A.children().each(function(){$(this).width($(this).width())});return A},stop:function(A,B){var C=B.item;C.removeClass("draggable-item-selected");z.savePosition(C.data("subtask-id"),C.index()+1)},start:function(A,B){B.item.addClass("draggable-item-selected")}}).disableSelection()};e.prototype.savePosition=function(C,z){var B=$(".subtasks-table").data("save-position-url");var A=this;this.app.showLoadingIcon();$.ajax({cache:false,url:B,contentType:"application/json",type:"POST",processData:false,data:JSON.stringify({subtask_id:C,position:z}),complete:function(){A.app.hideLoadingIcon()}})};function t(){}t.prototype.execute=function(){var B=$("#chart").data("metrics");var A=[];for(var z=0;z<B.length;z++){A.push([B[z].column_title,B[z].nb_tasks])}c3.generate({data:{columns:A,type:"donut"}})};function q(){}q.prototype.execute=function(){var B=$("#chart").data("metrics");var A=[];for(var z=0;z<B.length;z++){A.push([B[z].user,B[z].nb_tasks])}c3.generate({data:{columns:A,type:"donut"}})};function c(){}c.prototype.execute=function(){var F=$("#chart").data("metrics");var E=[];var z=[];var A=[];var C=d3.time.format("%Y-%m-%d");var G=d3.time.format($("#chart").data("date-format"));for(var D=0;D<F.length;D++){for(var B=0;B<F[D].length;B++){if(D==0){E.push([F[D][B]]);if(B>0){z.push(F[D][B])}}else{E[B].push(F[D][B]);if(B==0){A.push(G(C.parse(F[D][B])))}}}}c3.generate({data:{columns:E,type:"area-spline",groups:[z]},axis:{x:{type:"category",categories:A}}})};function p(){}p.prototype.execute=function(){var E=$("#chart").data("metrics");var D=[[$("#chart").data("label-total")]];var z=[];var B=d3.time.format("%Y-%m-%d");var F=d3.time.format($("#chart").data("date-format"));for(var C=0;C<E.length;C++){for(var A=0;A<E[C].length;A++){if(C==0){D.push([E[C][A]])}else{D[A+1].push(E[C][A]);if(A>0){if(D[0][C]==undefined){D[0].push(0)}D[0][C]+=E[C][A]}if(A==0){z.push(F(B.parse(E[C][A])))}}}}c3.generate({data:{columns:D},axis:{x:{type:"category",categories:z}}})};function h(z){this.app=z}h.prototype.execute=function(){var B=$("#chart").data("metrics");var C=[$("#chart").data("label")];var z=[];for(var A in B){C.push(B[A].average);z.push(B[A].title)}c3.generate({data:{columns:[C],type:"bar"},bar:{width:{ratio:0.5}},axis:{x:{type:"category",categories:z},y:{tick:{format:this.app.formatDuration}}},legend:{show:false}})};function y(z){this.app=z}y.prototype.execute=function(){var B=$("#chart").data("metrics");var C=[$("#chart").data("label")];var z=[];for(var A=0;A<B.length;A++){C.push(B[A].time_spent);z.push(B[A].title)}c3.generate({data:{columns:[C],type:"bar"},bar:{width:{ratio:0.5}},axis:{x:{type:"category",categories:z},y:{tick:{format:this.app.formatDuration}}},legend:{show:false}})};function v(z){this.app=z}v.prototype.execute=function(){var F=$("#chart").data("metrics");var E=[$("#chart").data("label-cycle")];var B=[$("#chart").data("label-lead")];var A=[];var D={};D[$("#chart").data("label-cycle")]="area";D[$("#chart").data("label-lead")]="area-spline";var z={};z[$("#chart").data("label-lead")]="#afb42b";z[$("#chart").data("label-cycle")]="#4e342e";for(var C=0;C<F.length;C++){E.push(parseInt(F[C].avg_cycle_time));B.push(parseInt(F[C].avg_lead_time));A.push(F[C].day)}c3.generate({data:{columns:[B,E],types:D,colors:z},axis:{x:{type:"category",categories:A},y:{tick:{format:this.app.formatDuration}}}})};function i(z){this.app=z}i.prototype.execute=function(){var E=$("#chart").data("metrics");var A=$("#chart").data("label-open");var z=$("#chart").data("label-closed");var F=[$("#chart").data("label-spent")];var D=[$("#chart").data("label-estimated")];var C=[];for(var B in E){F.push(parseFloat(E[B].time_spent));D.push(parseFloat(E[B].time_estimated));C.push(B=="open"?A:z)}c3.generate({data:{columns:[F,D],type:"bar"},bar:{width:{ratio:0.2}},axis:{x:{type:"category",categories:C}},legend:{show:true}})};function x(){this.routes={}}x.prototype.addRoute=function(A,z){this.routes[A]=z};x.prototype.dispatch=function(A){for(var B in this.routes){if(document.getElementById(B)){var z=Object.create(this.routes[B].prototype);this.routes[B].apply(z,[A]);z.execute();break}}};jQuery(document).ready(function(){var A=new n();var z=new x();z.addRoute("board",k);z.addRoute("calendar",j);z.addRoute("screenshot-zone",d);z.addRoute("analytic-task-repartition",t);z.addRoute("analytic-user-repartition",q);z.addRoute("analytic-cfd",c);z.addRoute("analytic-burndown",p);z.addRoute("analytic-avg-time-column",h);z.addRoute("analytic-task-time-column",y);z.addRoute("analytic-lead-cycle-time",v);z.addRoute("analytic-compare-hours",i);z.addRoute("gantt-chart",b);z.dispatch(A);A.listen()})})(); \ No newline at end of file
diff --git a/assets/js/src/App.js b/assets/js/src/App.js
index 7651b3ec..b20a73c1 100644
--- a/assets/js/src/App.js
+++ b/assets/js/src/App.js
@@ -1,13 +1,16 @@
function App() {
this.board = new Board(this);
this.markdown = new Markdown();
- this.sidebar = new Sidebar();
this.search = new Search(this);
- this.swimlane = new Swimlane();
+ this.swimlane = new Swimlane(this);
this.dropdown = new Dropdown();
this.tooltip = new Tooltip(this);
this.popover = new Popover(this);
- this.task = new Task();
+ this.task = new Task(this);
+ this.project = new Project();
+ this.subtask = new Subtask(this);
+ this.column = new Column(this);
+ this.file = new FileUpload(this);
this.keyboardShortcuts();
this.chosen();
this.poll();
@@ -16,29 +19,22 @@ function App() {
$(".alert-fade-out").delay(4000).fadeOut(800, function() {
$(this).remove();
});
-
- // Reload page when a destination project is changed
- var reloading_project = false;
- $("select.task-reload-project-destination").change(function() {
- if (! reloading_project) {
- $(".loading-icon").show();
- reloading_project = true;
- window.location = $(this).data("redirect").replace(/PROJECT_ID/g, $(this).val());
- }
- });
}
App.prototype.listen = function() {
+ this.project.listen();
this.popover.listen();
this.markdown.listen();
- this.sidebar.listen();
this.tooltip.listen();
this.dropdown.listen();
this.search.listen();
this.task.listen();
this.swimlane.listen();
+ this.subtask.listen();
+ this.column.listen();
+ this.file.listen();
this.search.focus();
- this.taskAutoComplete();
+ this.autoComplete();
this.datePicker();
this.focus();
};
@@ -125,36 +121,47 @@ App.prototype.datePicker = function() {
// timeFormat: 'h:mm tt',
constrainInput: false
});
-
- $(".hasDatepicker").on("blur", function(e) { $(this).datepicker("hide"); });
};
-App.prototype.taskAutoComplete = function() {
- // Task auto-completion
- if ($(".task-autocomplete").length) {
+App.prototype.autoComplete = function() {
+ $(".autocomplete").each(function() {
+ var input = $(this);
+ var field = input.data("dst-field");
+ var extraField = input.data("dst-extra-field");
- if ($('.opposite_task_id').val() == '') {
- $(".task-autocomplete").parent().find("input[type=submit]").attr('disabled','disabled');
+ if ($('#form-' + field).val() == '') {
+ input.parent().find("input[type=submit]").attr('disabled','disabled');
}
- $(".task-autocomplete").autocomplete({
- source: $(".task-autocomplete").data("search-url"),
+ input.autocomplete({
+ source: input.data("search-url"),
minLength: 1,
select: function(event, ui) {
- var field = $(".task-autocomplete").data("dst-field");
$("input[name=" + field + "]").val(ui.item.id);
- $(".task-autocomplete").parent().find("input[type=submit]").removeAttr('disabled');
+ if (extraField) {
+ $("input[name=" + extraField + "]").val(ui.item[extraField]);
+ }
+
+ input.parent().find("input[type=submit]").removeAttr('disabled');
}
});
- }
+ });
};
App.prototype.chosen = function() {
- $(".chosen-select").chosen({
- width: "180px",
- no_results_text: $(".chosen-select").data("notfound"),
- disable_search_threshold: 10
+ $(".chosen-select").each(function() {
+ var searchThreshold = $(this).data("search-threshold");
+
+ if (searchThreshold === undefined) {
+ searchThreshold = 10;
+ }
+
+ $(this).chosen({
+ width: "180px",
+ no_results_text: $(this).data("notfound"),
+ disable_search_threshold: searchThreshold
+ });
});
$(".select-auto-redirect").change(function() {
diff --git a/assets/js/src/Board.js b/assets/js/src/Board.js
index b98aa366..c99f3cd3 100644
--- a/assets/js/src/Board.js
+++ b/assets/js/src/Board.js
@@ -1,18 +1,19 @@
function Board(app) {
this.app = app;
this.checkInterval = null;
+ this.savingInProgress = false;
}
Board.prototype.execute = function() {
this.app.swimlane.refresh();
this.restoreColumnViewMode();
this.compactView();
- this.columnScrolling();
this.poll();
this.keyboardShortcuts();
this.listen();
this.dragAndDrop();
+ $(window).on("load", this.columnScrolling);
$(window).resize(this.columnScrolling);
};
@@ -42,8 +43,7 @@ Board.prototype.reloadFilters = function(search) {
};
Board.prototype.check = function() {
- if (this.app.isVisible()) {
-
+ if (this.app.isVisible() && ! this.savingInProgress) {
var self = this;
this.app.showLoadingIcon();
@@ -59,7 +59,9 @@ Board.prototype.check = function() {
};
Board.prototype.save = function(taskId, columnId, position, swimlaneId) {
+ var self = this;
this.app.showLoadingIcon();
+ this.savingInProgress = true;
$.ajax({
cache: false,
@@ -73,8 +75,14 @@ Board.prototype.save = function(taskId, columnId, position, swimlaneId) {
"swimlane_id": swimlaneId,
"position": position
}),
- success: this.refresh.bind(this),
- error: this.app.hideLoadingIcon.bind(this)
+ success: function(data) {
+ self.refresh(data);
+ this.savingInProgress = false;
+ },
+ error: function() {
+ self.app.hideLoadingIcon();
+ this.savingInProgress = false;
+ }
});
};
@@ -83,12 +91,12 @@ Board.prototype.refresh = function(data) {
this.app.refresh();
this.app.swimlane.refresh();
- this.columnScrolling();
this.app.hideLoadingIcon();
this.listen();
this.dragAndDrop();
this.compactView();
this.restoreColumnViewMode();
+ this.columnScrolling();
};
Board.prototype.dragAndDrop = function() {
@@ -100,13 +108,22 @@ Board.prototype.dragAndDrop = function() {
placeholder: "draggable-placeholder",
items: ".draggable-item",
stop: function(event, ui) {
- ui.item.removeClass("draggable-item-selected");
- self.save(
- ui.item.attr('data-task-id'),
- ui.item.parent().attr("data-column-id"),
- ui.item.index() + 1,
- ui.item.parent().attr('data-swimlane-id')
- );
+ var task = ui.item;
+ var taskId = task.attr('data-task-id');
+ var taskPosition = task.attr('data-position');
+ var taskColumnId = task.attr('data-column-id');
+ var taskSwimlaneId = task.attr('data-swimlane-id');
+
+ var newColumnId = task.parent().attr("data-column-id");
+ var newSwimlaneId = task.parent().attr('data-swimlane-id');
+ var newPosition = task.index() + 1;
+
+ task.removeClass("draggable-item-selected");
+
+ if (newColumnId != taskColumnId || newSwimlaneId != taskSwimlaneId || newPosition != taskPosition) {
+ self.changeTaskState(taskId);
+ self.save(taskId, newColumnId, newPosition, newSwimlaneId);
+ }
},
start: function(event, ui) {
ui.item.addClass("draggable-item-selected");
@@ -122,6 +139,12 @@ Board.prototype.dragAndDrop = function() {
$(".board-task-list").sortable(params);
};
+Board.prototype.changeTaskState = function(taskId) {
+ var task = $("div[data-task-id=" + taskId + "]");
+ task.addClass('task-board-saving-state');
+ task.find('.task-board-saving-icon').show();
+};
+
Board.prototype.listen = function() {
var self = this;
@@ -141,27 +164,40 @@ Board.prototype.listen = function() {
self.toggleColumnScrolling();
});
- $(document).on("click", ".board-column-title", function() {
+ $(document).on("click", ".board-toggle-column-view", function() {
self.toggleColumnViewMode($(this).data("column-id"));
});
};
Board.prototype.toggleColumnScrolling = function() {
- var scrolling = localStorage.getItem("column_scroll") || 1;
+ var scrolling = localStorage.getItem("column_scroll");
+
+ if (scrolling == undefined) {
+ scrolling = 1;
+ }
+
localStorage.setItem("column_scroll", scrolling == 0 ? 1 : 0);
this.columnScrolling();
};
Board.prototype.columnScrolling = function() {
if (localStorage.getItem("column_scroll") == 0) {
+ var height = 80;
+
$(".filter-max-height").show();
$(".filter-min-height").hide();
+ $(".board-rotation-wrapper").css("min-height", '');
$(".board-task-list").each(function() {
- $(this).css("min-height", 80);
- $(this).css("height", '');
- $(".board-rotation-wrapper").css("min-height", '');
+ var columnHeight = $(this).height();
+
+ if (columnHeight > height) {
+ height = columnHeight;
+ }
});
+
+ $(".board-task-list").css("min-height", height);
+ $(".board-task-list").css("height", '');
}
else {
@@ -180,7 +216,7 @@ Board.prototype.columnScrolling = function() {
});
}
else {
- var height = $(window).height() - 145;
+ var height = $(window).height() - 170;
$(".board-task-list").css("height", height);
$(".board-rotation-wrapper").css("min-height", height);
diff --git a/assets/js/src/Column.js b/assets/js/src/Column.js
new file mode 100644
index 00000000..2c8ebbd2
--- /dev/null
+++ b/assets/js/src/Column.js
@@ -0,0 +1,59 @@
+function Column(app) {
+ this.app = app;
+}
+
+Column.prototype.listen = function() {
+ this.dragAndDrop();
+};
+
+Column.prototype.dragAndDrop = function() {
+ var self = this;
+
+ $(".draggable-row-handle").mouseenter(function() {
+ $(this).parent().parent().addClass("draggable-item-hover");
+ }).mouseleave(function() {
+ $(this).parent().parent().removeClass("draggable-item-hover");
+ });
+
+ $(".columns-table tbody").sortable({
+ forcePlaceholderSize: true,
+ handle: "td:first i",
+ helper: function(e, ui) {
+ ui.children().each(function() {
+ $(this).width($(this).width());
+ });
+
+ return ui;
+ },
+ stop: function(event, ui) {
+ var column = ui.item;
+ column.removeClass("draggable-item-selected");
+ self.savePosition(column.data("column-id"), column.index() + 1);
+ },
+ start: function(event, ui) {
+ ui.item.addClass("draggable-item-selected");
+ }
+ }).disableSelection();
+};
+
+Column.prototype.savePosition = function(columnId, position) {
+ var url = $(".columns-table").data("save-position-url");
+ var self = this;
+
+ this.app.showLoadingIcon();
+
+ $.ajax({
+ cache: false,
+ url: url,
+ contentType: "application/json",
+ type: "POST",
+ processData: false,
+ data: JSON.stringify({
+ "column_id": columnId,
+ "position": position
+ }),
+ complete: function() {
+ self.app.hideLoadingIcon();
+ }
+ });
+};
diff --git a/assets/js/src/CompareHoursColumnChart.js b/assets/js/src/CompareHoursColumnChart.js
new file mode 100644
index 00000000..bed16144
--- /dev/null
+++ b/assets/js/src/CompareHoursColumnChart.js
@@ -0,0 +1,39 @@
+function CompareHoursColumnChart(app) {
+ this.app = app;
+}
+
+CompareHoursColumnChart.prototype.execute = function() {
+ var metrics = $("#chart").data("metrics");
+ var labelOpen = $("#chart").data("label-open");
+ var labelClosed = $("#chart").data("label-closed");
+ var spent = [$("#chart").data("label-spent")];
+ var estimated = [$("#chart").data("label-estimated")];
+ var categories = [];
+
+ for (var status in metrics) {
+ spent.push(parseFloat(metrics[status].time_spent));
+ estimated.push(parseFloat(metrics[status].time_estimated));
+ categories.push(status == 'open' ? labelOpen : labelClosed);
+ }
+
+ c3.generate({
+ data: {
+ columns: [spent, estimated],
+ type: 'bar'
+ },
+ bar: {
+ width: {
+ ratio: 0.2
+ }
+ },
+ axis: {
+ x: {
+ type: 'category',
+ categories: categories
+ }
+ },
+ legend: {
+ show: true
+ }
+ });
+};
diff --git a/assets/js/src/Dropdown.js b/assets/js/src/Dropdown.js
index e04603d8..61738da9 100644
--- a/assets/js/src/Dropdown.js
+++ b/assets/js/src/Dropdown.js
@@ -14,26 +14,53 @@ Dropdown.prototype.listen = function() {
self.close();
var submenu = $(this).next('ul');
- var submenuHeight = 240;
var offset = $(this).offset();
- var height = $(this).height();
// Clone the submenu outside of the column to avoid clipping issue with overflow
$("body").append(jQuery("<div>", {"id": "dropdown"}));
submenu.clone().appendTo("#dropdown");
var clone = $("#dropdown ul");
- clone.css('left', offset.left);
+ clone.addClass('dropdown-submenu-open');
+
+ var submenuHeight = clone.outerHeight();
+ var submenuWidth = clone.outerWidth();
- if (offset.top + submenuHeight - $(window).scrollTop() > $(window).height()) {
- clone.css('top', offset.top - submenuHeight - height);
+ if (offset.top + submenuHeight - $(window).scrollTop() < $(window).height() || $(window).scrollTop() + offset.top < submenuHeight) {
+ clone.css('top', offset.top + $(this).height());
}
else {
- clone.css('top', offset.top + height);
+ clone.css('top', offset.top - submenuHeight - 5);
}
- clone.addClass('dropdown-submenu-open');
+ if (offset.left + submenuWidth > $(window).width()) {
+ clone.css('left', offset.left - submenuWidth + $(this).outerWidth());
+ }
+ else {
+ clone.css('left', offset.left);
+ }
});
+
+ $(document).on('click', '.dropdown-submenu-open li', function(e) {
+ if ($(e.target).is('li')) {
+ $(this).find('a:visible')[0].click(); // Calling native click() not the jQuery one
+ }
+ });
+
+ // User mention autocomplete
+ $('textarea[data-mention-search-url]').textcomplete([{
+ match: /(^|\s)@(\w*)$/,
+ search: function (term, callback) {
+ var url = $('textarea[data-mention-search-url]').data('mention-search-url');
+ $.getJSON(url, { q: term })
+ .done(function (resp) { callback(resp); })
+ .fail(function () { callback([]); });
+ },
+ replace: function (value) {
+ return '$1@' + value + ' ';
+ },
+ cache: true
+ }], {className: "textarea-dropdown"});
};
Dropdown.prototype.close = function() {
diff --git a/assets/js/src/FileUpload.js b/assets/js/src/FileUpload.js
new file mode 100644
index 00000000..a8816bcd
--- /dev/null
+++ b/assets/js/src/FileUpload.js
@@ -0,0 +1,124 @@
+function FileUpload(app) {
+ this.app = app;
+ this.files = [];
+ this.currentFile = 0;
+}
+
+FileUpload.prototype.listen = function() {
+ var dropzone = document.getElementById("file-dropzone");
+ var self = this;
+
+ if (dropzone) {
+ dropzone.ondragover = dropzone.ondragenter = function(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ dropzone.ondrop = function(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ self.files = e.dataTransfer.files;
+ self.show();
+ $("#file-error-max-size").hide();
+ }
+
+ $(document).on("click", "#file-browser", function(e) {
+ e.preventDefault();
+ $("#file-form-element").get(0).click();
+ });
+
+ $(document).on("click", "#file-upload-button", function(e) {
+ e.preventDefault();
+ self.currentFile = 0;
+ self.checkFiles();
+ });
+
+ $("#file-form-element").change(function() {
+ self.files = document.getElementById("file-form-element").files;
+ self.show();
+ $("#file-error-max-size").hide();
+ });
+ }
+};
+
+FileUpload.prototype.show = function() {
+ $("#file-list").remove();
+
+ if (this.files.length > 0) {
+ $("#file-upload-button").prop("disabled", false);
+ $("#file-dropzone-inner").hide();
+
+ var ul = jQuery("<ul>", {"id": "file-list"});
+
+ for (var i = 0; i < this.files.length; i++) {
+ var percentage = jQuery("<span>", {"id": "file-percentage-" + i}).append("(0%)");
+ var progress = jQuery("<progress>", {"id": "file-progress-" + i, "value": 0});
+ var li = jQuery("<li>", {"id": "file-label-" + i})
+ .append(progress)
+ .append("&nbsp;")
+ .append(this.files[i].name)
+ .append("&nbsp;")
+ .append(percentage);
+
+ ul.append(li);
+ }
+
+ $("#file-dropzone").append(ul);
+ } else {
+ $("#file-dropzone-inner").show();
+ }
+};
+
+FileUpload.prototype.checkFiles = function() {
+ var max = parseInt($("#file-dropzone").data("max-size"));
+
+ for (var i = 0; i < this.files.length; i++) {
+ if (this.files[i].size > max) {
+ $("#file-error-max-size").show();
+ $("#file-label-" + i).addClass("file-error");
+ $("#file-upload-button").prop("disabled", true);
+ return;
+ }
+ }
+
+ this.uploadFiles();
+};
+
+FileUpload.prototype.uploadFiles = function() {
+ if (this.files.length > 0) {
+ this.uploadFile(this.files[this.currentFile]);
+ }
+};
+
+FileUpload.prototype.uploadFile = function(file) {
+ var dropzone = document.getElementById("file-dropzone");
+ var url = dropzone.dataset.url;
+ var xhr = new XMLHttpRequest();
+ var fd = new FormData();
+
+ xhr.upload.addEventListener("progress", this.updateProgress.bind(this));
+ xhr.upload.addEventListener("load", this.transferComplete.bind(this));
+
+ xhr.open("POST", url, true);
+ fd.append('files[]', file);
+ xhr.send(fd);
+};
+
+FileUpload.prototype.updateProgress = function(e) {
+ if (e.lengthComputable) {
+ $("#file-progress-" + this.currentFile).val(e.loaded / e.total);
+ $("#file-percentage-" + this.currentFile).text('(' + Math.floor((e.loaded / e.total) * 100) + '%)');
+ }
+};
+
+FileUpload.prototype.transferComplete = function() {
+ this.currentFile++;
+
+ if (this.currentFile < this.files.length) {
+ this.uploadFile(this.files[this.currentFile]);
+ } else {
+ $("#file-upload-button").prop("disabled", true);
+ $("#file-upload-button").parent().hide();
+ $("#file-done").show();
+ }
+};
diff --git a/assets/js/src/Gantt.js b/assets/js/src/Gantt.js
index 380371d1..6f536552 100644
--- a/assets/js/src/Gantt.js
+++ b/assets/js/src/Gantt.js
@@ -185,7 +185,7 @@ Gantt.prototype.addBlocks = function(slider, start) {
var block = jQuery("<div>", {
"class": "ganttview-block tooltip" + (this.options.allowMoves ? " ganttview-block-movable" : ""),
- "title": this.getBarTooltip(this.data[i]),
+ "title": this.getBarTooltip(series),
"css": {
"width": ((size * this.options.cellWidth) - 9) + "px",
"margin-left": (offset * this.options.cellWidth) + "px"
@@ -193,23 +193,25 @@ Gantt.prototype.addBlocks = function(slider, start) {
}).append(text);
if (size >= 2) {
- text.append(this.data[i].progress);
+ text.append(series.progress);
}
- block.data("record", this.data[i]);
- this.setBarColor(block, this.data[i]);
-
- block.append(jQuery("<div>", {
- "css": {
- "z-index": 0,
- "position": "absolute",
- "top": 0,
- "bottom": 0,
- "background-color": series.color.border,
- "width": series.progress,
- "opacity": 0.4
- }
- }));
+ block.data("record", series);
+ this.setBarColor(block, series);
+
+ if (series.progress != "0%") {
+ block.append(jQuery("<div>", {
+ "css": {
+ "z-index": 0,
+ "position": "absolute",
+ "top": 0,
+ "bottom": 0,
+ "background-color": series.color.border,
+ "width": series.progress,
+ "opacity": 0.4
+ }
+ }));
+ }
jQuery(rows[rowIdx]).append(block);
rowIdx = rowIdx + 1;
diff --git a/assets/js/src/Popover.js b/assets/js/src/Popover.js
index 8d72dec8..5614de85 100644
--- a/assets/js/src/Popover.js
+++ b/assets/js/src/Popover.js
@@ -13,7 +13,7 @@ Popover.prototype.open = function(link) {
self.app.dropdown.close();
$.get(link, function(content) {
- $("body").append('<div id="popover-container"><div id="popover-content">' + content + '</div></div>');
+ $("body").prepend('<div id="popover-container"><div id="popover-content">' + content + '</div></div>');
self.app.refresh();
self.router.dispatch(this.app);
self.afterOpen();
@@ -35,10 +35,11 @@ Popover.prototype.onClick = function(e) {
e.preventDefault();
e.stopPropagation();
- var link = e.target.getAttribute("href");
+ var target = e.currentTarget || e.target;
+ var link = target.getAttribute("href");
if (! link) {
- link = e.target.getAttribute("data-href");
+ link = target.getAttribute("data-href");
}
if (link) {
@@ -55,26 +56,47 @@ Popover.prototype.listen = function() {
Popover.prototype.afterOpen = function() {
var self = this;
- var taskForm = $("#task-form");
+ var popoverForm = $("#popover-content .popover-form");
- if (taskForm) {
- taskForm.on("submit", function(e) {
+ // Submit forms with Ajax request
+ if (popoverForm) {
+ popoverForm.on("submit", function(e) {
e.preventDefault();
$.ajax({
type: "POST",
- url: taskForm.attr("action"),
- data: taskForm.serialize(),
+ url: popoverForm.attr("action"),
+ data: popoverForm.serialize(),
success: function(data, textStatus, request) {
- if (request.getResponseHeader("X-Ajax-Redirect")) {
- window.location = request.getResponseHeader("X-Ajax-Redirect");
- }
- else {
- $("#popover-content").html(data);
- self.afterOpen();
- }
+ self.afterSubmit(data, request, self);
}
});
});
}
+
+ // Submit link with Ajax request
+ $(document).on("click", ".popover-link", function(e) {
+ e.preventDefault();
+
+ $.ajax({
+ type: "GET",
+ url: $(this).attr("href"),
+ success: function(data, textStatus, request) {
+ self.afterSubmit(data, request, self);
+ }
+ });
+ });
+};
+
+Popover.prototype.afterSubmit = function(data, request, self) {
+ var redirect = request.getResponseHeader("X-Ajax-Redirect");
+
+ if (redirect) {
+ window.location = redirect === 'self' ? window.location.href.split("#")[0] : redirect;
+ }
+ else {
+ $("#popover-content").html(data);
+ $("#popover-content input[autofocus]").focus();
+ self.afterOpen();
+ }
};
diff --git a/assets/js/src/Project.js b/assets/js/src/Project.js
new file mode 100644
index 00000000..19941f03
--- /dev/null
+++ b/assets/js/src/Project.js
@@ -0,0 +1,28 @@
+function Project() {
+}
+
+Project.prototype.listen = function() {
+ $('.project-change-role').on('change', function() {
+ $.ajax({
+ cache: false,
+ url: $(this).data('url'),
+ contentType: "application/json",
+ type: "POST",
+ processData: false,
+ data: JSON.stringify({
+ "id": $(this).data('id'),
+ "role": $(this).val()
+ })
+ });
+ });
+
+ $('#project-creation-form #form-src_project_id').on('change', function() {
+ var srcProjectId = $(this).val();
+
+ if (srcProjectId == 0) {
+ $(".project-creation-options").hide();
+ } else {
+ $(".project-creation-options").show();
+ }
+ });
+};
diff --git a/assets/js/src/Router.js b/assets/js/src/Router.js
index 0c96262c..ab23c0fd 100644
--- a/assets/js/src/Router.js
+++ b/assets/js/src/Router.js
@@ -30,6 +30,7 @@ jQuery(document).ready(function() {
router.addRoute('analytic-avg-time-column', AvgTimeColumnChart);
router.addRoute('analytic-task-time-column', TaskTimeColumnChart);
router.addRoute('analytic-lead-cycle-time', LeadCycleTimeChart);
+ router.addRoute('analytic-compare-hours', CompareHoursColumnChart);
router.addRoute('gantt-chart', Gantt);
router.dispatch(app);
app.listen();
diff --git a/assets/js/src/Search.js b/assets/js/src/Search.js
index de4d5959..4fbfee46 100644
--- a/assets/js/src/Search.js
+++ b/assets/js/src/Search.js
@@ -40,6 +40,15 @@ Search.prototype.listen = function() {
Search.prototype.keyboardShortcuts = function() {
var self = this;
+ // Switch view mode for projects: go to the overview page
+ Mousetrap.bind("v o", function(e) {
+ var link = $(".view-overview");
+
+ if (link.length) {
+ window.location = link.attr('href');
+ }
+ });
+
// Switch view mode for projects: go to the board
Mousetrap.bind("v b", function(e) {
var link = $(".view-board");
diff --git a/assets/js/src/Sidebar.js b/assets/js/src/Sidebar.js
deleted file mode 100644
index 0794d6b3..00000000
--- a/assets/js/src/Sidebar.js
+++ /dev/null
@@ -1,25 +0,0 @@
-function Sidebar() {
-}
-
-Sidebar.prototype.expand = function(e) {
- e.preventDefault();
- $(".sidebar-container").removeClass("sidebar-collapsed");
- $(".sidebar-collapse").show();
- $(".sidebar h2").show();
- $(".sidebar ul").show();
- $(".sidebar-expand").hide();
-};
-
-Sidebar.prototype.collapse = function(e) {
- e.preventDefault();
- $(".sidebar-container").addClass("sidebar-collapsed");
- $(".sidebar-expand").show();
- $(".sidebar h2").hide();
- $(".sidebar ul").hide();
- $(".sidebar-collapse").hide();
-};
-
-Sidebar.prototype.listen = function() {
- $(document).on("click", ".sidebar-collapse", this.collapse);
- $(document).on("click", ".sidebar-expand", this.expand);
-};
diff --git a/assets/js/src/Subtask.js b/assets/js/src/Subtask.js
new file mode 100644
index 00000000..7670095e
--- /dev/null
+++ b/assets/js/src/Subtask.js
@@ -0,0 +1,94 @@
+function Subtask(app) {
+ this.app = app;
+}
+
+Subtask.prototype.listen = function() {
+ var self = this;
+
+ this.dragAndDrop();
+
+ $(document).on("click", ".subtask-toggle-status", function(e) {
+ e.preventDefault();
+ var el = $(this);
+
+ $.ajax({
+ cache: false,
+ url: el.attr("href"),
+ success: function(data) {
+ if (el.hasClass("subtask-refresh-table")) {
+ $(".subtasks-table").replaceWith(data);
+ } else {
+ el.replaceWith(data);
+ }
+
+ self.dragAndDrop();
+ }
+ });
+ });
+
+ $(document).on("click", ".subtask-toggle-timer", function(e) {
+ e.preventDefault();
+ var el = $(this);
+
+ $.ajax({
+ cache: false,
+ url: el.attr("href"),
+ success: function(data) {
+ $(".subtasks-table").replaceWith(data);
+ self.dragAndDrop();
+ }
+ });
+ });
+};
+
+Subtask.prototype.dragAndDrop = function() {
+ var self = this;
+
+ $(".draggable-row-handle").mouseenter(function() {
+ $(this).parent().parent().addClass("draggable-item-hover");
+ }).mouseleave(function() {
+ $(this).parent().parent().removeClass("draggable-item-hover");
+ });
+
+ $(".subtasks-table tbody").sortable({
+ forcePlaceholderSize: true,
+ handle: "td:first i",
+ helper: function(e, ui) {
+ ui.children().each(function() {
+ $(this).width($(this).width());
+ });
+
+ return ui;
+ },
+ stop: function(event, ui) {
+ var subtask = ui.item;
+ subtask.removeClass("draggable-item-selected");
+ self.savePosition(subtask.data("subtask-id"), subtask.index() + 1);
+ },
+ start: function(event, ui) {
+ ui.item.addClass("draggable-item-selected");
+ }
+ }).disableSelection();
+};
+
+Subtask.prototype.savePosition = function(subtaskId, position) {
+ var url = $(".subtasks-table").data("save-position-url");
+ var self = this;
+
+ this.app.showLoadingIcon();
+
+ $.ajax({
+ cache: false,
+ url: url,
+ contentType: "application/json",
+ type: "POST",
+ processData: false,
+ data: JSON.stringify({
+ "subtask_id": subtaskId,
+ "position": position
+ }),
+ complete: function() {
+ self.app.hideLoadingIcon();
+ }
+ });
+};
diff --git a/assets/js/src/Swimlane.js b/assets/js/src/Swimlane.js
index 340b40a0..60dbf41f 100644
--- a/assets/js/src/Swimlane.js
+++ b/assets/js/src/Swimlane.js
@@ -1,4 +1,5 @@
-function Swimlane() {
+function Swimlane(app) {
+ this.app = app;
}
Swimlane.prototype.getStorageKey = function() {
@@ -53,6 +54,7 @@ Swimlane.prototype.refresh = function() {
Swimlane.prototype.listen = function() {
var self = this;
+ self.dragAndDrop();
$(document).on('click', ".board-swimlane-toggle", function(e) {
e.preventDefault();
@@ -67,3 +69,55 @@ Swimlane.prototype.listen = function() {
}
});
};
+
+Swimlane.prototype.dragAndDrop = function() {
+ var self = this;
+
+ $(".draggable-row-handle").mouseenter(function() {
+ $(this).parent().parent().addClass("draggable-item-hover");
+ }).mouseleave(function() {
+ $(this).parent().parent().removeClass("draggable-item-hover");
+ });
+
+ $(".swimlanes-table tbody").sortable({
+ forcePlaceholderSize: true,
+ handle: "td:first i",
+ helper: function(e, ui) {
+ ui.children().each(function() {
+ $(this).width($(this).width());
+ });
+
+ return ui;
+ },
+ stop: function(event, ui) {
+ var swimlane = ui.item;
+ swimlane.removeClass("draggable-item-selected");
+ self.savePosition(swimlane.data("swimlane-id"), swimlane.index() + 1);
+ },
+ start: function(event, ui) {
+ ui.item.addClass("draggable-item-selected");
+ }
+ }).disableSelection();
+};
+
+Swimlane.prototype.savePosition = function(swimlaneId, position) {
+ var url = $(".swimlanes-table").data("save-position-url");
+ var self = this;
+
+ this.app.showLoadingIcon();
+
+ $.ajax({
+ cache: false,
+ url: url,
+ contentType: "application/json",
+ type: "POST",
+ processData: false,
+ data: JSON.stringify({
+ "swimlane_id": swimlaneId,
+ "position": position
+ }),
+ complete: function() {
+ self.app.hideLoadingIcon();
+ }
+ });
+};
diff --git a/assets/js/src/Task.js b/assets/js/src/Task.js
index b3dc1b63..955a5752 100644
--- a/assets/js/src/Task.js
+++ b/assets/js/src/Task.js
@@ -1,10 +1,51 @@
-function Task() {
+function Task(app) {
+ this.app = app;
}
Task.prototype.listen = function() {
+ var self = this;
+ var reloadingProjectId = 0;
+
+ // Change color
$(document).on("click", ".color-square", function() {
$(".color-square-selected").removeClass("color-square-selected");
$(this).addClass("color-square-selected");
$("#form-color_id").val($(this).data("color-id"));
});
+
+ // Assign to me
+ $(document).on("click", ".assign-me", function(e) {
+ e.preventDefault();
+
+ var currentId = $(this).data("current-id");
+ var dropdownId = "#" + $(this).data("target-id");
+
+ if ($(dropdownId + ' option[value=' + currentId + ']').length) {
+ $(dropdownId).val(currentId);
+ }
+ });
+
+ // Reload page when a destination project is changed
+ $(document).on("change", "select.task-reload-project-destination", function() {
+ if (reloadingProjectId > 0) {
+ $(this).val(reloadingProjectId);
+ }
+ else {
+ reloadingProjectId = $(this).val();
+ var url = $(this).data("redirect").replace(/PROJECT_ID/g, reloadingProjectId);
+
+ $(".loading-icon").show();
+
+ $.ajax({
+ type: "GET",
+ url: url,
+ success: function(data, textStatus, request) {
+ reloadingProjectId = 0;
+ $(".loading-icon").hide();
+
+ self.app.popover.afterSubmit(data, request, self.app.popover);
+ }
+ });
+ }
+ });
};
diff --git a/assets/js/src/Tooltip.js b/assets/js/src/Tooltip.js
index 0ec8b268..f3ef55f9 100644
--- a/assets/js/src/Tooltip.js
+++ b/assets/js/src/Tooltip.js
@@ -48,21 +48,6 @@ Tooltip.prototype.listen = function() {
var position = $(_this).tooltip("option", "position");
position.of = $(_this);
tooltip.position(position);
-
- // Toggle subtasks status
- $('#tooltip-subtasks a').not(".popover").click(function(e) {
-
- e.preventDefault();
- e.stopPropagation();
-
- if ($(this).hasClass("popover-subtask-restriction")) {
- self.app.popover.open($(this).attr('href'));
- $(_this).tooltip('close');
- }
- else {
- $.get($(this).attr('href'), setTooltipContent);
- }
- });
});
return '<i class="fa fa-spinner fa-spin"></i>';
diff --git a/assets/js/vendor/jquery.textcomplete.js b/assets/js/vendor/jquery.textcomplete.js
new file mode 100644
index 00000000..0e548a65
--- /dev/null
+++ b/assets/js/vendor/jquery.textcomplete.js
@@ -0,0 +1,1227 @@
+(function (factory) {
+ if (typeof define === 'function' && define.amd) {
+ // AMD. Register as an anonymous module.
+ define(['jquery'], factory);
+ } else if (typeof module === "object" && module.exports) {
+ var $ = require('jquery');
+ module.exports = factory($);
+ } else {
+ // Browser globals
+ factory(jQuery);
+ }
+}(function (jQuery) {
+
+/*!
+ * jQuery.textcomplete
+ *
+ * Repository: https://github.com/yuku-t/jquery-textcomplete
+ * License: MIT (https://github.com/yuku-t/jquery-textcomplete/blob/master/LICENSE)
+ * Author: Yuku Takahashi
+ */
+
+if (typeof jQuery === 'undefined') {
+ throw new Error('jQuery.textcomplete requires jQuery');
+}
+
++function ($) {
+ 'use strict';
+
+ var warn = function (message) {
+ if (console.warn) { console.warn(message); }
+ };
+
+ var id = 1;
+
+ $.fn.textcomplete = function (strategies, option) {
+ var args = Array.prototype.slice.call(arguments);
+ return this.each(function () {
+ var self = this;
+ var $this = $(this);
+ var completer = $this.data('textComplete');
+ if (!completer) {
+ option || (option = {});
+ option._oid = id++; // unique object id
+ completer = new $.fn.textcomplete.Completer(this, option);
+ $this.data('textComplete', completer);
+ }
+ if (typeof strategies === 'string') {
+ if (!completer) return;
+ args.shift()
+ completer[strategies].apply(completer, args);
+ if (strategies === 'destroy') {
+ $this.removeData('textComplete');
+ }
+ } else {
+ // For backward compatibility.
+ // TODO: Remove at v0.4
+ $.each(strategies, function (obj) {
+ $.each(['header', 'footer', 'placement', 'maxCount'], function (name) {
+ if (obj[name]) {
+ completer.option[name] = obj[name];
+ warn(name + 'as a strategy param is deprecated. Use option.');
+ delete obj[name];
+ }
+ });
+ });
+ completer.register($.fn.textcomplete.Strategy.parse(strategies, {
+ el: self,
+ $el: $this
+ }));
+ }
+ });
+ };
+
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ // Exclusive execution control utility.
+ //
+ // func - The function to be locked. It is executed with a function named
+ // `free` as the first argument. Once it is called, additional
+ // execution are ignored until the free is invoked. Then the last
+ // ignored execution will be replayed immediately.
+ //
+ // Examples
+ //
+ // var lockedFunc = lock(function (free) {
+ // setTimeout(function { free(); }, 1000); // It will be free in 1 sec.
+ // console.log('Hello, world');
+ // });
+ // lockedFunc(); // => 'Hello, world'
+ // lockedFunc(); // none
+ // lockedFunc(); // none
+ // // 1 sec past then
+ // // => 'Hello, world'
+ // lockedFunc(); // => 'Hello, world'
+ // lockedFunc(); // none
+ //
+ // Returns a wrapped function.
+ var lock = function (func) {
+ var locked, queuedArgsToReplay;
+
+ return function () {
+ // Convert arguments into a real array.
+ var args = Array.prototype.slice.call(arguments);
+ if (locked) {
+ // Keep a copy of this argument list to replay later.
+ // OK to overwrite a previous value because we only replay
+ // the last one.
+ queuedArgsToReplay = args;
+ return;
+ }
+ locked = true;
+ var self = this;
+ args.unshift(function replayOrFree() {
+ if (queuedArgsToReplay) {
+ // Other request(s) arrived while we were locked.
+ // Now that the lock is becoming available, replay
+ // the latest such request, then call back here to
+ // unlock (or replay another request that arrived
+ // while this one was in flight).
+ var replayArgs = queuedArgsToReplay;
+ queuedArgsToReplay = undefined;
+ replayArgs.unshift(replayOrFree);
+ func.apply(self, replayArgs);
+ } else {
+ locked = false;
+ }
+ });
+ func.apply(this, args);
+ };
+ };
+
+ var isString = function (obj) {
+ return Object.prototype.toString.call(obj) === '[object String]';
+ };
+
+ var isFunction = function (obj) {
+ return Object.prototype.toString.call(obj) === '[object Function]';
+ };
+
+ var uniqueId = 0;
+
+ function Completer(element, option) {
+ this.$el = $(element);
+ this.id = 'textcomplete' + uniqueId++;
+ this.strategies = [];
+ this.views = [];
+ this.option = $.extend({}, Completer._getDefaults(), option);
+
+ if (!this.$el.is('input[type=text]') && !this.$el.is('textarea') && !element.isContentEditable && element.contentEditable != 'true') {
+ throw new Error('textcomplete must be called on a Textarea or a ContentEditable.');
+ }
+
+ if (element === document.activeElement) {
+ // element has already been focused. Initialize view objects immediately.
+ this.initialize()
+ } else {
+ // Initialize view objects lazily.
+ var self = this;
+ this.$el.one('focus.' + this.id, function () { self.initialize(); });
+ }
+ }
+
+ Completer._getDefaults = function () {
+ if (!Completer.DEFAULTS) {
+ Completer.DEFAULTS = {
+ appendTo: $('body'),
+ zIndex: '100'
+ };
+ }
+
+ return Completer.DEFAULTS;
+ }
+
+ $.extend(Completer.prototype, {
+ // Public properties
+ // -----------------
+
+ id: null,
+ option: null,
+ strategies: null,
+ adapter: null,
+ dropdown: null,
+ $el: null,
+
+ // Public methods
+ // --------------
+
+ initialize: function () {
+ var element = this.$el.get(0);
+ // Initialize view objects.
+ this.dropdown = new $.fn.textcomplete.Dropdown(element, this, this.option);
+ var Adapter, viewName;
+ if (this.option.adapter) {
+ Adapter = this.option.adapter;
+ } else {
+ if (this.$el.is('textarea') || this.$el.is('input[type=text]')) {
+ viewName = typeof element.selectionEnd === 'number' ? 'Textarea' : 'IETextarea';
+ } else {
+ viewName = 'ContentEditable';
+ }
+ Adapter = $.fn.textcomplete[viewName];
+ }
+ this.adapter = new Adapter(element, this, this.option);
+ },
+
+ destroy: function () {
+ this.$el.off('.' + this.id);
+ if (this.adapter) {
+ this.adapter.destroy();
+ }
+ if (this.dropdown) {
+ this.dropdown.destroy();
+ }
+ this.$el = this.adapter = this.dropdown = null;
+ },
+
+ // Invoke textcomplete.
+ trigger: function (text, skipUnchangedTerm) {
+ if (!this.dropdown) { this.initialize(); }
+ text != null || (text = this.adapter.getTextFromHeadToCaret());
+ var searchQuery = this._extractSearchQuery(text);
+ if (searchQuery.length) {
+ var term = searchQuery[1];
+ // Ignore shift-key, ctrl-key and so on.
+ if (skipUnchangedTerm && this._term === term) { return; }
+ this._term = term;
+ this._search.apply(this, searchQuery);
+ } else {
+ this._term = null;
+ this.dropdown.deactivate();
+ }
+ },
+
+ fire: function (eventName) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ this.$el.trigger(eventName, args);
+ return this;
+ },
+
+ register: function (strategies) {
+ Array.prototype.push.apply(this.strategies, strategies);
+ },
+
+ // Insert the value into adapter view. It is called when the dropdown is clicked
+ // or selected.
+ //
+ // value - The selected element of the array callbacked from search func.
+ // strategy - The Strategy object.
+ // e - Click or keydown event object.
+ select: function (value, strategy, e) {
+ this._term = null;
+ this.adapter.select(value, strategy, e);
+ this.fire('change').fire('textComplete:select', value, strategy);
+ this.adapter.focus();
+ },
+
+ // Private properties
+ // ------------------
+
+ _clearAtNext: true,
+ _term: null,
+
+ // Private methods
+ // ---------------
+
+ // Parse the given text and extract the first matching strategy.
+ //
+ // Returns an array including the strategy, the query term and the match
+ // object if the text matches an strategy; otherwise returns an empty array.
+ _extractSearchQuery: function (text) {
+ for (var i = 0; i < this.strategies.length; i++) {
+ var strategy = this.strategies[i];
+ var context = strategy.context(text);
+ if (context || context === '') {
+ var matchRegexp = isFunction(strategy.match) ? strategy.match(text) : strategy.match;
+ if (isString(context)) { text = context; }
+ var match = text.match(matchRegexp);
+ if (match) { return [strategy, match[strategy.index], match]; }
+ }
+ }
+ return []
+ },
+
+ // Call the search method of selected strategy..
+ _search: lock(function (free, strategy, term, match) {
+ var self = this;
+ strategy.search(term, function (data, stillSearching) {
+ if (!self.dropdown.shown) {
+ self.dropdown.activate();
+ }
+ if (self._clearAtNext) {
+ // The first callback in the current lock.
+ self.dropdown.clear();
+ self._clearAtNext = false;
+ }
+ self.dropdown.setPosition(self.adapter.getCaretPosition());
+ self.dropdown.render(self._zip(data, strategy, term));
+ if (!stillSearching) {
+ // The last callback in the current lock.
+ free();
+ self._clearAtNext = true; // Call dropdown.clear at the next time.
+ }
+ }, match);
+ }),
+
+ // Build a parameter for Dropdown#render.
+ //
+ // Examples
+ //
+ // this._zip(['a', 'b'], 's');
+ // //=> [{ value: 'a', strategy: 's' }, { value: 'b', strategy: 's' }]
+ _zip: function (data, strategy, term) {
+ return $.map(data, function (value) {
+ return { value: value, strategy: strategy, term: term };
+ });
+ }
+ });
+
+ $.fn.textcomplete.Completer = Completer;
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ var $window = $(window);
+
+ var include = function (zippedData, datum) {
+ var i, elem;
+ var idProperty = datum.strategy.idProperty
+ for (i = 0; i < zippedData.length; i++) {
+ elem = zippedData[i];
+ if (elem.strategy !== datum.strategy) continue;
+ if (idProperty) {
+ if (elem.value[idProperty] === datum.value[idProperty]) return true;
+ } else {
+ if (elem.value === datum.value) return true;
+ }
+ }
+ return false;
+ };
+
+ var dropdownViews = {};
+ $(document).on('click', function (e) {
+ var id = e.originalEvent && e.originalEvent.keepTextCompleteDropdown;
+ $.each(dropdownViews, function (key, view) {
+ if (key !== id) { view.deactivate(); }
+ });
+ });
+
+ var commands = {
+ SKIP_DEFAULT: 0,
+ KEY_UP: 1,
+ KEY_DOWN: 2,
+ KEY_ENTER: 3,
+ KEY_PAGEUP: 4,
+ KEY_PAGEDOWN: 5,
+ KEY_ESCAPE: 6
+ };
+
+ // Dropdown view
+ // =============
+
+ // Construct Dropdown object.
+ //
+ // element - Textarea or contenteditable element.
+ function Dropdown(element, completer, option) {
+ this.$el = Dropdown.createElement(option);
+ this.completer = completer;
+ this.id = completer.id + 'dropdown';
+ this._data = []; // zipped data.
+ this.$inputEl = $(element);
+ this.option = option;
+
+ // Override setPosition method.
+ if (option.listPosition) { this.setPosition = option.listPosition; }
+ if (option.height) { this.$el.height(option.height); }
+ var self = this;
+ $.each(['maxCount', 'placement', 'footer', 'header', 'noResultsMessage', 'className'], function (_i, name) {
+ if (option[name] != null) { self[name] = option[name]; }
+ });
+ this._bindEvents(element);
+ dropdownViews[this.id] = this;
+ }
+
+ $.extend(Dropdown, {
+ // Class methods
+ // -------------
+
+ createElement: function (option) {
+ var $parent = option.appendTo;
+ if (!($parent instanceof $)) { $parent = $($parent); }
+ var $el = $('<ul></ul>')
+ .addClass('textcomplete-dropdown')
+ .attr('id', 'textcomplete-dropdown-' + option._oid)
+ .css({
+ display: 'none',
+ left: 0,
+ position: 'absolute',
+ zIndex: option.zIndex
+ })
+ .appendTo($parent);
+ return $el;
+ }
+ });
+
+ $.extend(Dropdown.prototype, {
+ // Public properties
+ // -----------------
+
+ $el: null, // jQuery object of ul.dropdown-menu element.
+ $inputEl: null, // jQuery object of target textarea.
+ completer: null,
+ footer: null,
+ header: null,
+ id: null,
+ maxCount: 10,
+ placement: '',
+ shown: false,
+ data: [], // Shown zipped data.
+ className: '',
+
+ // Public methods
+ // --------------
+
+ destroy: function () {
+ // Don't remove $el because it may be shared by several textcompletes.
+ this.deactivate();
+
+ this.$el.off('.' + this.id);
+ this.$inputEl.off('.' + this.id);
+ this.clear();
+ this.$el = this.$inputEl = this.completer = null;
+ delete dropdownViews[this.id]
+ },
+
+ render: function (zippedData) {
+ var contentsHtml = this._buildContents(zippedData);
+ var unzippedData = $.map(this.data, function (d) { return d.value; });
+ if (this.data.length) {
+ this._renderHeader(unzippedData);
+ this._renderFooter(unzippedData);
+ if (contentsHtml) {
+ this._renderContents(contentsHtml);
+ this._fitToBottom();
+ this._activateIndexedItem();
+ }
+ this._setScroll();
+ } else if (this.noResultsMessage) {
+ this._renderNoResultsMessage(unzippedData);
+ } else if (this.shown) {
+ this.deactivate();
+ }
+ },
+
+ setPosition: function (pos) {
+ this.$el.css(this._applyPlacement(pos));
+
+ // Make the dropdown fixed if the input is also fixed
+ // This can't be done during init, as textcomplete may be used on multiple elements on the same page
+ // Because the same dropdown is reused behind the scenes, we need to recheck every time the dropdown is showed
+ var position = 'absolute';
+ // Check if input or one of its parents has positioning we need to care about
+ this.$inputEl.add(this.$inputEl.parents()).each(function() {
+ if($(this).css('position') === 'absolute') // The element has absolute positioning, so it's all OK
+ return false;
+ if($(this).css('position') === 'fixed') {
+ position = 'fixed';
+ return false;
+ }
+ });
+ this.$el.css({ position: position }); // Update positioning
+
+ return this;
+ },
+
+ clear: function () {
+ this.$el.html('');
+ this.data = [];
+ this._index = 0;
+ this._$header = this._$footer = this._$noResultsMessage = null;
+ },
+
+ activate: function () {
+ if (!this.shown) {
+ this.clear();
+ this.$el.show();
+ if (this.className) { this.$el.addClass(this.className); }
+ this.completer.fire('textComplete:show');
+ this.shown = true;
+ }
+ return this;
+ },
+
+ deactivate: function () {
+ if (this.shown) {
+ this.$el.hide();
+ if (this.className) { this.$el.removeClass(this.className); }
+ this.completer.fire('textComplete:hide');
+ this.shown = false;
+ }
+ return this;
+ },
+
+ isUp: function (e) {
+ return e.keyCode === 38 || (e.ctrlKey && e.keyCode === 80); // UP, Ctrl-P
+ },
+
+ isDown: function (e) {
+ return e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78); // DOWN, Ctrl-N
+ },
+
+ isEnter: function (e) {
+ var modifiers = e.ctrlKey || e.altKey || e.metaKey || e.shiftKey;
+ return !modifiers && (e.keyCode === 13 || e.keyCode === 9 || (this.option.completeOnSpace === true && e.keyCode === 32)) // ENTER, TAB
+ },
+
+ isPageup: function (e) {
+ return e.keyCode === 33; // PAGEUP
+ },
+
+ isPagedown: function (e) {
+ return e.keyCode === 34; // PAGEDOWN
+ },
+
+ isEscape: function (e) {
+ return e.keyCode === 27; // ESCAPE
+ },
+
+ // Private properties
+ // ------------------
+
+ _data: null, // Currently shown zipped data.
+ _index: null,
+ _$header: null,
+ _$noResultsMessage: null,
+ _$footer: null,
+
+ // Private methods
+ // ---------------
+
+ _bindEvents: function () {
+ this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this));
+ this.$el.on('touchstart.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this));
+ this.$el.on('mouseover.' + this.id, '.textcomplete-item', $.proxy(this._onMouseover, this));
+ this.$inputEl.on('keydown.' + this.id, $.proxy(this._onKeydown, this));
+ },
+
+ _onClick: function (e) {
+ var $el = $(e.target);
+ e.preventDefault();
+ e.originalEvent.keepTextCompleteDropdown = this.id;
+ if (!$el.hasClass('textcomplete-item')) {
+ $el = $el.closest('.textcomplete-item');
+ }
+ var datum = this.data[parseInt($el.data('index'), 10)];
+ this.completer.select(datum.value, datum.strategy, e);
+ var self = this;
+ // Deactive at next tick to allow other event handlers to know whether
+ // the dropdown has been shown or not.
+ setTimeout(function () {
+ self.deactivate();
+ if (e.type === 'touchstart') {
+ self.$inputEl.focus();
+ }
+ }, 0);
+ },
+
+ // Activate hovered item.
+ _onMouseover: function (e) {
+ var $el = $(e.target);
+ e.preventDefault();
+ if (!$el.hasClass('textcomplete-item')) {
+ $el = $el.closest('.textcomplete-item');
+ }
+ this._index = parseInt($el.data('index'), 10);
+ this._activateIndexedItem();
+ },
+
+ _onKeydown: function (e) {
+ if (!this.shown) { return; }
+
+ var command;
+
+ if ($.isFunction(this.option.onKeydown)) {
+ command = this.option.onKeydown(e, commands);
+ }
+
+ if (command == null) {
+ command = this._defaultKeydown(e);
+ }
+
+ switch (command) {
+ case commands.KEY_UP:
+ e.preventDefault();
+ this._up();
+ break;
+ case commands.KEY_DOWN:
+ e.preventDefault();
+ this._down();
+ break;
+ case commands.KEY_ENTER:
+ e.preventDefault();
+ this._enter(e);
+ break;
+ case commands.KEY_PAGEUP:
+ e.preventDefault();
+ this._pageup();
+ break;
+ case commands.KEY_PAGEDOWN:
+ e.preventDefault();
+ this._pagedown();
+ break;
+ case commands.KEY_ESCAPE:
+ e.preventDefault();
+ this.deactivate();
+ break;
+ }
+ },
+
+ _defaultKeydown: function (e) {
+ if (this.isUp(e)) {
+ return commands.KEY_UP;
+ } else if (this.isDown(e)) {
+ return commands.KEY_DOWN;
+ } else if (this.isEnter(e)) {
+ return commands.KEY_ENTER;
+ } else if (this.isPageup(e)) {
+ return commands.KEY_PAGEUP;
+ } else if (this.isPagedown(e)) {
+ return commands.KEY_PAGEDOWN;
+ } else if (this.isEscape(e)) {
+ return commands.KEY_ESCAPE;
+ }
+ },
+
+ _up: function () {
+ if (this._index === 0) {
+ this._index = this.data.length - 1;
+ } else {
+ this._index -= 1;
+ }
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _down: function () {
+ if (this._index === this.data.length - 1) {
+ this._index = 0;
+ } else {
+ this._index += 1;
+ }
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _enter: function (e) {
+ var datum = this.data[parseInt(this._getActiveElement().data('index'), 10)];
+ this.completer.select(datum.value, datum.strategy, e);
+ this.deactivate();
+ },
+
+ _pageup: function () {
+ var target = 0;
+ var threshold = this._getActiveElement().position().top - this.$el.innerHeight();
+ this.$el.children().each(function (i) {
+ if ($(this).position().top + $(this).outerHeight() > threshold) {
+ target = i;
+ return false;
+ }
+ });
+ this._index = target;
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _pagedown: function () {
+ var target = this.data.length - 1;
+ var threshold = this._getActiveElement().position().top + this.$el.innerHeight();
+ this.$el.children().each(function (i) {
+ if ($(this).position().top > threshold) {
+ target = i;
+ return false
+ }
+ });
+ this._index = target;
+ this._activateIndexedItem();
+ this._setScroll();
+ },
+
+ _activateIndexedItem: function () {
+ this.$el.find('.textcomplete-item.active').removeClass('active');
+ this._getActiveElement().addClass('active');
+ },
+
+ _getActiveElement: function () {
+ return this.$el.children('.textcomplete-item:nth(' + this._index + ')');
+ },
+
+ _setScroll: function () {
+ var $activeEl = this._getActiveElement();
+ var itemTop = $activeEl.position().top;
+ var itemHeight = $activeEl.outerHeight();
+ var visibleHeight = this.$el.innerHeight();
+ var visibleTop = this.$el.scrollTop();
+ if (this._index === 0 || this._index == this.data.length - 1 || itemTop < 0) {
+ this.$el.scrollTop(itemTop + visibleTop);
+ } else if (itemTop + itemHeight > visibleHeight) {
+ this.$el.scrollTop(itemTop + itemHeight + visibleTop - visibleHeight);
+ }
+ },
+
+ _buildContents: function (zippedData) {
+ var datum, i, index;
+ var html = '';
+ for (i = 0; i < zippedData.length; i++) {
+ if (this.data.length === this.maxCount) break;
+ datum = zippedData[i];
+ if (include(this.data, datum)) { continue; }
+ index = this.data.length;
+ this.data.push(datum);
+ html += '<li class="textcomplete-item" data-index="' + index + '"><a>';
+ html += datum.strategy.template(datum.value, datum.term);
+ html += '</a></li>';
+ }
+ return html;
+ },
+
+ _renderHeader: function (unzippedData) {
+ if (this.header) {
+ if (!this._$header) {
+ this._$header = $('<li class="textcomplete-header"></li>').prependTo(this.$el);
+ }
+ var html = $.isFunction(this.header) ? this.header(unzippedData) : this.header;
+ this._$header.html(html);
+ }
+ },
+
+ _renderFooter: function (unzippedData) {
+ if (this.footer) {
+ if (!this._$footer) {
+ this._$footer = $('<li class="textcomplete-footer"></li>').appendTo(this.$el);
+ }
+ var html = $.isFunction(this.footer) ? this.footer(unzippedData) : this.footer;
+ this._$footer.html(html);
+ }
+ },
+
+ _renderNoResultsMessage: function (unzippedData) {
+ if (this.noResultsMessage) {
+ if (!this._$noResultsMessage) {
+ this._$noResultsMessage = $('<li class="textcomplete-no-results-message"></li>').appendTo(this.$el);
+ }
+ var html = $.isFunction(this.noResultsMessage) ? this.noResultsMessage(unzippedData) : this.noResultsMessage;
+ this._$noResultsMessage.html(html);
+ }
+ },
+
+ _renderContents: function (html) {
+ if (this._$footer) {
+ this._$footer.before(html);
+ } else {
+ this.$el.append(html);
+ }
+ },
+
+ _fitToBottom: function() {
+ var windowScrollBottom = $window.scrollTop() + $window.height();
+ var height = this.$el.height();
+ if ((this.$el.position().top + height) > windowScrollBottom) {
+ this.$el.offset({top: windowScrollBottom - height});
+ }
+ },
+
+ _applyPlacement: function (position) {
+ // If the 'placement' option set to 'top', move the position above the element.
+ if (this.placement.indexOf('top') !== -1) {
+ // Overwrite the position object to set the 'bottom' property instead of the top.
+ position = {
+ top: 'auto',
+ bottom: this.$el.parent().height() - position.top + position.lineHeight,
+ left: position.left
+ };
+ } else {
+ position.bottom = 'auto';
+ delete position.lineHeight;
+ }
+ if (this.placement.indexOf('absleft') !== -1) {
+ position.left = 0;
+ } else if (this.placement.indexOf('absright') !== -1) {
+ position.right = 0;
+ position.left = 'auto';
+ }
+ return position;
+ }
+ });
+
+ $.fn.textcomplete.Dropdown = Dropdown;
+ $.extend($.fn.textcomplete, commands);
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ // Memoize a search function.
+ var memoize = function (func) {
+ var memo = {};
+ return function (term, callback) {
+ if (memo[term]) {
+ callback(memo[term]);
+ } else {
+ func.call(this, term, function (data) {
+ memo[term] = (memo[term] || []).concat(data);
+ callback.apply(null, arguments);
+ });
+ }
+ };
+ };
+
+ function Strategy(options) {
+ $.extend(this, options);
+ if (this.cache) { this.search = memoize(this.search); }
+ }
+
+ Strategy.parse = function (strategiesArray, params) {
+ return $.map(strategiesArray, function (strategy) {
+ var strategyObj = new Strategy(strategy);
+ strategyObj.el = params.el;
+ strategyObj.$el = params.$el;
+ return strategyObj;
+ });
+ };
+
+ $.extend(Strategy.prototype, {
+ // Public properties
+ // -----------------
+
+ // Required
+ match: null,
+ replace: null,
+ search: null,
+
+ // Optional
+ cache: false,
+ context: function () { return true; },
+ index: 2,
+ template: function (obj) { return obj; },
+ idProperty: null
+ });
+
+ $.fn.textcomplete.Strategy = Strategy;
+
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ var now = Date.now || function () { return new Date().getTime(); };
+
+ // Returns a function, that, as long as it continues to be invoked, will not
+ // be triggered. The function will be called after it stops being called for
+ // `wait` msec.
+ //
+ // This utility function was originally implemented at Underscore.js.
+ var debounce = function (func, wait) {
+ var timeout, args, context, timestamp, result;
+ var later = function () {
+ var last = now() - timestamp;
+ if (last < wait) {
+ timeout = setTimeout(later, wait - last);
+ } else {
+ timeout = null;
+ result = func.apply(context, args);
+ context = args = null;
+ }
+ };
+
+ return function () {
+ context = this;
+ args = arguments;
+ timestamp = now();
+ if (!timeout) {
+ timeout = setTimeout(later, wait);
+ }
+ return result;
+ };
+ };
+
+ function Adapter () {}
+
+ $.extend(Adapter.prototype, {
+ // Public properties
+ // -----------------
+
+ id: null, // Identity.
+ completer: null, // Completer object which creates it.
+ el: null, // Textarea element.
+ $el: null, // jQuery object of the textarea.
+ option: null,
+
+ // Public methods
+ // --------------
+
+ initialize: function (element, completer, option) {
+ this.el = element;
+ this.$el = $(element);
+ this.id = completer.id + this.constructor.name;
+ this.completer = completer;
+ this.option = option;
+
+ if (this.option.debounce) {
+ this._onKeyup = debounce(this._onKeyup, this.option.debounce);
+ }
+
+ this._bindEvents();
+ },
+
+ destroy: function () {
+ this.$el.off('.' + this.id); // Remove all event handlers.
+ this.$el = this.el = this.completer = null;
+ },
+
+ // Update the element with the given value and strategy.
+ //
+ // value - The selected object. It is one of the item of the array
+ // which was callbacked from the search function.
+ // strategy - The Strategy associated with the selected value.
+ select: function (/* value, strategy */) {
+ throw new Error('Not implemented');
+ },
+
+ // Returns the caret's relative coordinates from body's left top corner.
+ //
+ // FIXME: Calculate the left top corner of `this.option.appendTo` element.
+ getCaretPosition: function () {
+ var position = this._getCaretRelativePosition();
+ var offset = this.$el.offset();
+ position.top += offset.top;
+ position.left += offset.left;
+ return position;
+ },
+
+ // Focus on the element.
+ focus: function () {
+ this.$el.focus();
+ },
+
+ // Private methods
+ // ---------------
+
+ _bindEvents: function () {
+ this.$el.on('keyup.' + this.id, $.proxy(this._onKeyup, this));
+ },
+
+ _onKeyup: function (e) {
+ if (this._skipSearch(e)) { return; }
+ this.completer.trigger(this.getTextFromHeadToCaret(), true);
+ },
+
+ // Suppress searching if it returns true.
+ _skipSearch: function (clickEvent) {
+ switch (clickEvent.keyCode) {
+ case 13: // ENTER
+ case 40: // DOWN
+ case 38: // UP
+ return true;
+ }
+ if (clickEvent.ctrlKey) switch (clickEvent.keyCode) {
+ case 78: // Ctrl-N
+ case 80: // Ctrl-P
+ return true;
+ }
+ }
+ });
+
+ $.fn.textcomplete.Adapter = Adapter;
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ // Textarea adapter
+ // ================
+ //
+ // Managing a textarea. It doesn't know a Dropdown.
+ function Textarea(element, completer, option) {
+ this.initialize(element, completer, option);
+ }
+
+ Textarea.DIV_PROPERTIES = {
+ left: -9999,
+ position: 'absolute',
+ top: 0,
+ whiteSpace: 'pre-wrap'
+ }
+
+ Textarea.COPY_PROPERTIES = [
+ 'border-width', 'font-family', 'font-size', 'font-style', 'font-variant',
+ 'font-weight', 'height', 'letter-spacing', 'word-spacing', 'line-height',
+ 'text-decoration', 'text-align', 'width', 'padding-top', 'padding-right',
+ 'padding-bottom', 'padding-left', 'margin-top', 'margin-right',
+ 'margin-bottom', 'margin-left', 'border-style', 'box-sizing', 'tab-size'
+ ];
+
+ $.extend(Textarea.prototype, $.fn.textcomplete.Adapter.prototype, {
+ // Public methods
+ // --------------
+
+ // Update the textarea with the given value and strategy.
+ select: function (value, strategy, e) {
+ var pre = this.getTextFromHeadToCaret();
+ var post = this.el.value.substring(this.el.selectionEnd);
+ var newSubstr = strategy.replace(value, e);
+ if (typeof newSubstr !== 'undefined') {
+ if ($.isArray(newSubstr)) {
+ post = newSubstr[1] + post;
+ newSubstr = newSubstr[0];
+ }
+ pre = pre.replace(strategy.match, newSubstr);
+ this.$el.val(pre + post);
+ this.el.selectionStart = this.el.selectionEnd = pre.length;
+ }
+ },
+
+ // Private methods
+ // ---------------
+
+ // Returns the caret's relative coordinates from textarea's left top corner.
+ //
+ // Browser native API does not provide the way to know the position of
+ // caret in pixels, so that here we use a kind of hack to accomplish
+ // the aim. First of all it puts a dummy div element and completely copies
+ // the textarea's style to the element, then it inserts the text and a
+ // span element into the textarea.
+ // Consequently, the span element's position is the thing what we want.
+ _getCaretRelativePosition: function () {
+ var dummyDiv = $('<div></div>').css(this._copyCss())
+ .text(this.getTextFromHeadToCaret());
+ var span = $('<span></span>').text('.').appendTo(dummyDiv);
+ this.$el.before(dummyDiv);
+ var position = span.position();
+ position.top += span.height() - this.$el.scrollTop();
+ position.lineHeight = span.height();
+ dummyDiv.remove();
+ return position;
+ },
+
+ _copyCss: function () {
+ return $.extend({
+ // Set 'scroll' if a scrollbar is being shown; otherwise 'auto'.
+ overflow: this.el.scrollHeight > this.el.offsetHeight ? 'scroll' : 'auto'
+ }, Textarea.DIV_PROPERTIES, this._getStyles());
+ },
+
+ _getStyles: (function ($) {
+ var color = $('<div></div>').css(['color']).color;
+ if (typeof color !== 'undefined') {
+ return function () {
+ return this.$el.css(Textarea.COPY_PROPERTIES);
+ };
+ } else { // jQuery < 1.8
+ return function () {
+ var $el = this.$el;
+ var styles = {};
+ $.each(Textarea.COPY_PROPERTIES, function (i, property) {
+ styles[property] = $el.css(property);
+ });
+ return styles;
+ };
+ }
+ })($),
+
+ getTextFromHeadToCaret: function () {
+ return this.el.value.substring(0, this.el.selectionEnd);
+ }
+ });
+
+ $.fn.textcomplete.Textarea = Textarea;
+}(jQuery);
+
++function ($) {
+ 'use strict';
+
+ var sentinelChar = '吶';
+
+ function IETextarea(element, completer, option) {
+ this.initialize(element, completer, option);
+ $('<span>' + sentinelChar + '</span>').css({
+ position: 'absolute',
+ top: -9999,
+ left: -9999
+ }).insertBefore(element);
+ }
+
+ $.extend(IETextarea.prototype, $.fn.textcomplete.Textarea.prototype, {
+ // Public methods
+ // --------------
+
+ select: function (value, strategy, e) {
+ var pre = this.getTextFromHeadToCaret();
+ var post = this.el.value.substring(pre.length);
+ var newSubstr = strategy.replace(value, e);
+ if (typeof newSubstr !== 'undefined') {
+ if ($.isArray(newSubstr)) {
+ post = newSubstr[1] + post;
+ newSubstr = newSubstr[0];
+ }
+ pre = pre.replace(strategy.match, newSubstr);
+ this.$el.val(pre + post);
+ this.el.focus();
+ var range = this.el.createTextRange();
+ range.collapse(true);
+ range.moveEnd('character', pre.length);
+ range.moveStart('character', pre.length);
+ range.select();
+ }
+ },
+
+ getTextFromHeadToCaret: function () {
+ this.el.focus();
+ var range = document.selection.createRange();
+ range.moveStart('character', -this.el.value.length);
+ var arr = range.text.split(sentinelChar)
+ return arr.length === 1 ? arr[0] : arr[1];
+ }
+ });
+
+ $.fn.textcomplete.IETextarea = IETextarea;
+}(jQuery);
+
+// NOTE: TextComplete plugin has contenteditable support but it does not work
+// fine especially on old IEs.
+// Any pull requests are REALLY welcome.
+
++function ($) {
+ 'use strict';
+
+ // ContentEditable adapter
+ // =======================
+ //
+ // Adapter for contenteditable elements.
+ function ContentEditable (element, completer, option) {
+ this.initialize(element, completer, option);
+ }
+
+ $.extend(ContentEditable.prototype, $.fn.textcomplete.Adapter.prototype, {
+ // Public methods
+ // --------------
+
+ // Update the content with the given value and strategy.
+ // When an dropdown item is selected, it is executed.
+ select: function (value, strategy, e) {
+ var pre = this.getTextFromHeadToCaret();
+ var sel = window.getSelection()
+ var range = sel.getRangeAt(0);
+ var selection = range.cloneRange();
+ selection.selectNodeContents(range.startContainer);
+ var content = selection.toString();
+ var post = content.substring(range.startOffset);
+ var newSubstr = strategy.replace(value, e);
+ if (typeof newSubstr !== 'undefined') {
+ if ($.isArray(newSubstr)) {
+ post = newSubstr[1] + post;
+ newSubstr = newSubstr[0];
+ }
+ pre = pre.replace(strategy.match, newSubstr);
+ range.selectNodeContents(range.startContainer);
+ range.deleteContents();
+ var node = document.createTextNode(pre + post);
+ range.insertNode(node);
+ range.setStart(node, pre.length);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ },
+
+ // Private methods
+ // ---------------
+
+ // Returns the caret's relative position from the contenteditable's
+ // left top corner.
+ //
+ // Examples
+ //
+ // this._getCaretRelativePosition()
+ // //=> { top: 18, left: 200, lineHeight: 16 }
+ //
+ // Dropdown's position will be decided using the result.
+ _getCaretRelativePosition: function () {
+ var range = window.getSelection().getRangeAt(0).cloneRange();
+ var node = document.createElement('span');
+ range.insertNode(node);
+ range.selectNodeContents(node);
+ range.deleteContents();
+ var $node = $(node);
+ var position = $node.offset();
+ position.left -= this.$el.offset().left;
+ position.top += $node.height() - this.$el.offset().top;
+ position.lineHeight = $node.height();
+ $node.remove();
+ return position;
+ },
+
+ // Returns the string between the first character and the caret.
+ // Completer will be triggered with the result for start autocompleting.
+ //
+ // Example
+ //
+ // // Suppose the html is '<b>hello</b> wor|ld' and | is the caret.
+ // this.getTextFromHeadToCaret()
+ // // => ' wor' // not '<b>hello</b> wor'
+ getTextFromHeadToCaret: function () {
+ var range = window.getSelection().getRangeAt(0);
+ var selection = range.cloneRange();
+ selection.selectNodeContents(range.startContainer);
+ return selection.toString().substring(0, range.startOffset);
+ }
+ });
+
+ $.fn.textcomplete.ContentEditable = ContentEditable;
+}(jQuery);
+
+return jQuery;
+}));
diff --git a/composer.json b/composer.json
index e6dcee80..ad3a1db8 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,6 @@
{
"name": "fguillot/kanboard",
+ "type": "project",
"description": "Kanban project management software",
"license": "MIT",
"authors": [
@@ -7,20 +8,31 @@
"name": "Frédéric Guillot"
}
],
+ "config": {
+ "preferred-install": "dist",
+ "optimize-autoloader": true,
+ "discard-changes": true
+ },
"require" : {
"php" : ">=5.3",
- "ext-mbstring" : "*",
"ext-gd" : "*",
+ "ext-mbstring" : "*",
+ "ext-hash" : "*",
+ "ext-openssl" : "*",
+ "ext-json" : "*",
+ "ext-ctype" : "*",
+ "ext-filter" : "*",
+ "ext-session" : "*",
"christian-riesen/otp" : "1.4",
"eluceo/ical": "0.8.0",
- "erusev/parsedown" : "1.5.4",
- "fabiang/xmpp" : "0.6.1",
+ "erusev/parsedown" : "1.6.0",
"fguillot/json-rpc" : "1.0.3",
- "fguillot/picodb" : "1.0.2",
+ "fguillot/picodb" : "1.0.5",
"fguillot/simpleLogger" : "1.0.0",
"fguillot/simple-validator" : "1.0.0",
- "league/html-to-markdown" : "~4.0",
+ "paragonie/random_compat": "@stable",
"pimple/pimple" : "~3.0",
+ "ramsey/array_column": "@stable",
"swiftmailer/swiftmailer" : "~5.4",
"symfony/console" : "~2.7",
"symfony/event-dispatcher" : "~2.7",
diff --git a/composer.lock b/composer.lock
index 38869985..238d6c7c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -1,10 +1,11 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
- "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
- "hash": "dedbdec5041e89f475dd55d5f11a9bd6",
+ "hash": "0e754e4bc3eec85b3d14c748f1ed857a",
+ "content-hash": "c7f7baadd60fdcf8fb9e2e3a7214357f",
"packages": [
{
"name": "christian-riesen/base32",
@@ -163,16 +164,16 @@
},
{
"name": "erusev/parsedown",
- "version": "1.5.4",
+ "version": "1.6.0",
"source": {
"type": "git",
"url": "https://github.com/erusev/parsedown.git",
- "reference": "0e89e3714bda18973184d30646306bb0a482bd96"
+ "reference": "3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/erusev/parsedown/zipball/0e89e3714bda18973184d30646306bb0a482bd96",
- "reference": "0e89e3714bda18973184d30646306bb0a482bd96",
+ "url": "https://api.github.com/repos/erusev/parsedown/zipball/3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7",
+ "reference": "3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7",
"shasum": ""
},
"type": "library",
@@ -198,65 +199,7 @@
"markdown",
"parser"
],
- "time": "2015-08-03 09:24:05"
- },
- {
- "name": "fabiang/xmpp",
- "version": "0.6.1",
- "source": {
- "type": "git",
- "url": "https://github.com/fabiang/xmpp.git",
- "reference": "47fdbe4a60ef0e726c4aaf39d6eb57afd42915c8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/fabiang/xmpp/zipball/47fdbe4a60ef0e726c4aaf39d6eb57afd42915c8",
- "reference": "47fdbe4a60ef0e726c4aaf39d6eb57afd42915c8",
- "shasum": ""
- },
- "require": {
- "php": ">=5.3.3",
- "psr/log": "~1.0"
- },
- "require-dev": {
- "behat/behat": "~2.5",
- "monolog/monolog": "~1.11",
- "phpunit/phpunit": "~4.3",
- "satooshi/php-coveralls": "~0.6"
- },
- "suggest": {
- "psr/log-implementation": "Allows more advanced logging of the xmpp connection"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Fabiang\\Xmpp\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-2-Clause"
- ],
- "authors": [
- {
- "name": "Fabian Grutschus",
- "email": "f.grutschus@lubyte.de",
- "homepage": "http://www.lubyte.de/",
- "role": "developer"
- }
- ],
- "description": "Library for XMPP protocol (Jabber) connections",
- "homepage": "https://github.com/fabiang/xmpp",
- "keywords": [
- "jabber",
- "xmpp"
- ],
- "time": "2014-11-20 08:59:24"
+ "time": "2015-10-04 16:44:32"
},
{
"name": "fguillot/json-rpc",
@@ -296,16 +239,16 @@
},
{
"name": "fguillot/picodb",
- "version": "v1.0.2",
+ "version": "v1.0.5",
"source": {
"type": "git",
"url": "https://github.com/fguillot/picoDb.git",
- "reference": "61f492c125d9195ce869447e2b2450adeb3b01d6"
+ "reference": "3b388ef12f8c57f3bca85d278a53cf6fa2d832b8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/fguillot/picoDb/zipball/61f492c125d9195ce869447e2b2450adeb3b01d6",
- "reference": "61f492c125d9195ce869447e2b2450adeb3b01d6",
+ "url": "https://api.github.com/repos/fguillot/picoDb/zipball/3b388ef12f8c57f3bca85d278a53cf6fa2d832b8",
+ "reference": "3b388ef12f8c57f3bca85d278a53cf6fa2d832b8",
"shasum": ""
},
"require": {
@@ -329,7 +272,7 @@
],
"description": "Minimalist database query builder",
"homepage": "https://github.com/fguillot/picoDb",
- "time": "2015-08-27 23:33:16"
+ "time": "2016-02-20 02:56:11"
},
{
"name": "fguillot/simple-validator",
@@ -453,38 +396,33 @@
"time": "2015-09-11 15:23:20"
},
{
- "name": "league/html-to-markdown",
- "version": "4.0.1",
+ "name": "paragonie/random_compat",
+ "version": "v1.2.0",
"source": {
"type": "git",
- "url": "https://github.com/thephpleague/html-to-markdown.git",
- "reference": "c496c27e01b9dce310e03afbcdf783347738f67b"
+ "url": "https://github.com/paragonie/random_compat.git",
+ "reference": "b0e69d10852716b2ccbdff69c75c477637220790"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/c496c27e01b9dce310e03afbcdf783347738f67b",
- "reference": "c496c27e01b9dce310e03afbcdf783347738f67b",
+ "url": "https://api.github.com/repos/paragonie/random_compat/zipball/b0e69d10852716b2ccbdff69c75c477637220790",
+ "reference": "b0e69d10852716b2ccbdff69c75c477637220790",
"shasum": ""
},
"require": {
- "ext-dom": "*",
- "ext-xml": "*",
- "php": ">=5.3.3"
+ "php": ">=5.2.0"
},
"require-dev": {
- "phpunit/phpunit": "4.*",
- "scrutinizer/ocular": "~1.1"
+ "phpunit/phpunit": "4.*|5.*"
},
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.1-dev"
- }
+ "suggest": {
+ "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
+ "type": "library",
"autoload": {
- "psr-4": {
- "League\\HTMLToMarkdown\\": "src/"
- }
+ "files": [
+ "lib/random.php"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -492,25 +430,18 @@
],
"authors": [
{
- "name": "Colin O'Dell",
- "email": "colinodell@gmail.com",
- "homepage": "http://www.colinodell.com",
- "role": "Lead Developer"
- },
- {
- "name": "Nick Cernis",
- "email": "nick@cern.is",
- "homepage": "http://modernnerd.net",
- "role": "Original Author"
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com"
}
],
- "description": "An HTML-to-markdown conversion helper for PHP",
- "homepage": "https://github.com/thephpleague/html-to-markdown",
+ "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
- "html",
- "markdown"
+ "csprng",
+ "pseudorandom",
+ "random"
],
- "time": "2015-09-01 22:39:54"
+ "time": "2016-02-06 03:52:05"
},
{
"name": "pimple/pimple",
@@ -597,6 +528,51 @@
"time": "2012-12-21 11:40:51"
},
{
+ "name": "ramsey/array_column",
+ "version": "1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ramsey/array_column.git",
+ "reference": "f8e52eb28e67eb50e613b451dd916abcf783c1db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ramsey/array_column/zipball/f8e52eb28e67eb50e613b451dd916abcf783c1db",
+ "reference": "f8e52eb28e67eb50e613b451dd916abcf783c1db",
+ "shasum": ""
+ },
+ "require-dev": {
+ "jakub-onderka/php-parallel-lint": "0.8.*",
+ "phpunit/phpunit": "~4.5",
+ "satooshi/php-coveralls": "0.6.*",
+ "squizlabs/php_codesniffer": "~2.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/array_column.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ben Ramsey",
+ "homepage": "http://benramsey.com"
+ }
+ ],
+ "description": "Provides functionality for array_column() to projects using PHP earlier than version 5.5.",
+ "homepage": "https://github.com/ramsey/array_column",
+ "keywords": [
+ "array",
+ "array_column",
+ "column"
+ ],
+ "time": "2015-03-20 22:07:39"
+ },
+ {
"name": "swiftmailer/swiftmailer",
"version": "v5.4.1",
"source": {
@@ -651,26 +627,26 @@
},
{
"name": "symfony/console",
- "version": "v2.7.5",
+ "version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "06cb17c013a82f94a3d840682b49425cd00a2161"
+ "reference": "d0239fb42f98dd02e7d342f793c5d2cdee0c478d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/06cb17c013a82f94a3d840682b49425cd00a2161",
- "reference": "06cb17c013a82f94a3d840682b49425cd00a2161",
+ "url": "https://api.github.com/repos/symfony/console/zipball/d0239fb42f98dd02e7d342f793c5d2cdee0c478d",
+ "reference": "d0239fb42f98dd02e7d342f793c5d2cdee0c478d",
"shasum": ""
},
"require": {
- "php": ">=5.3.9"
+ "php": ">=5.3.9",
+ "symfony/polyfill-mbstring": "~1.0"
},
"require-dev": {
"psr/log": "~1.0",
- "symfony/event-dispatcher": "~2.1",
- "symfony/phpunit-bridge": "~2.7",
- "symfony/process": "~2.1"
+ "symfony/event-dispatcher": "~2.1|~3.0.0",
+ "symfony/process": "~2.1|~3.0.0"
},
"suggest": {
"psr/log": "For using the console logger",
@@ -680,13 +656,16 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.7-dev"
+ "dev-master": "2.8-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
- }
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -704,20 +683,20 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
- "time": "2015-09-25 08:32:23"
+ "time": "2016-01-14 08:33:16"
},
{
"name": "symfony/event-dispatcher",
- "version": "v2.7.5",
+ "version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "ae4dcc2a8d3de98bd794167a3ccda1311597c5d9"
+ "reference": "ee278f7c851533e58ca307f66305ccb9188aceda"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ae4dcc2a8d3de98bd794167a3ccda1311597c5d9",
- "reference": "ae4dcc2a8d3de98bd794167a3ccda1311597c5d9",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ee278f7c851533e58ca307f66305ccb9188aceda",
+ "reference": "ee278f7c851533e58ca307f66305ccb9188aceda",
"shasum": ""
},
"require": {
@@ -725,11 +704,10 @@
},
"require-dev": {
"psr/log": "~1.0",
- "symfony/config": "~2.0,>=2.0.5",
- "symfony/dependency-injection": "~2.6",
- "symfony/expression-language": "~2.6",
- "symfony/phpunit-bridge": "~2.7",
- "symfony/stopwatch": "~2.3"
+ "symfony/config": "~2.0,>=2.0.5|~3.0.0",
+ "symfony/dependency-injection": "~2.6|~3.0.0",
+ "symfony/expression-language": "~2.6|~3.0.0",
+ "symfony/stopwatch": "~2.3|~3.0.0"
},
"suggest": {
"symfony/dependency-injection": "",
@@ -738,13 +716,16 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.7-dev"
+ "dev-master": "2.8-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
- }
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -762,40 +743,99 @@
],
"description": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
- "time": "2015-09-22 13:49:29"
+ "time": "2016-01-13 10:28:07"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "1289d16209491b584839022f29257ad859b8532d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/1289d16209491b584839022f29257ad859b8532d",
+ "reference": "1289d16209491b584839022f29257ad859b8532d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ },
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "time": "2016-01-20 09:13:37"
}
],
"packages-dev": [
{
"name": "symfony/stopwatch",
- "version": "v2.7.5",
+ "version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "08dd97b3f22ab9ee658cd16e6758f8c3c404336e"
+ "reference": "e3bc8e2a984f4382690a438c8bb650f3ffd71e73"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/08dd97b3f22ab9ee658cd16e6758f8c3c404336e",
- "reference": "08dd97b3f22ab9ee658cd16e6758f8c3c404336e",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/e3bc8e2a984f4382690a438c8bb650f3ffd71e73",
+ "reference": "e3bc8e2a984f4382690a438c8bb650f3ffd71e73",
"shasum": ""
},
"require": {
"php": ">=5.3.9"
},
- "require-dev": {
- "symfony/phpunit-bridge": "~2.7"
- },
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.7-dev"
+ "dev-master": "2.8-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\Stopwatch\\": ""
- }
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -813,18 +853,27 @@
],
"description": "Symfony Stopwatch Component",
"homepage": "https://symfony.com",
- "time": "2015-09-22 13:49:29"
+ "time": "2016-01-03 15:33:41"
}
],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": [],
+ "stability-flags": {
+ "paragonie/random_compat": 0,
+ "ramsey/array_column": 0
+ },
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=5.3",
+ "ext-gd": "*",
"ext-mbstring": "*",
- "ext-gd": "*"
+ "ext-hash": "*",
+ "ext-openssl": "*",
+ "ext-json": "*",
+ "ext-ctype": "*",
+ "ext-filter": "*",
+ "ext-session": "*"
},
"platform-dev": []
}
diff --git a/config.default.php b/config.default.php
index a790e1ea..52c0c143 100644
--- a/config.default.php
+++ b/config.default.php
@@ -11,7 +11,7 @@ define('DEBUG', false);
define('DEBUG_FILE', __DIR__.DIRECTORY_SEPARATOR.'data'.DIRECTORY_SEPARATOR.'debug.log');
// Plugins directory
-define('PLUGINS_DIR', 'data'.DIRECTORY_SEPARATOR.'plugins');
+define('PLUGINS_DIR', 'plugins');
// Folder for uploaded files
define('FILES_DIR', 'data'.DIRECTORY_SEPARATOR.'files');
@@ -32,19 +32,6 @@ 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');
@@ -78,6 +65,10 @@ define('LDAP_SSL_VERIFY', true);
// Enable LDAP START_TLS
define('LDAP_START_TLS', false);
+// By default Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive)
+// Set to true if you want to preserve the case
+define('LDAP_USERNAME_CASE_SENSITIVE', false);
+
// LDAP bind type: "anonymous", "user" or "proxy"
define('LDAP_BIND_TYPE', 'anonymous');
@@ -88,88 +79,56 @@ define('LDAP_USERNAME', null);
// LDAP password to use for proxy mode
define('LDAP_PASSWORD', null);
-// LDAP account base, i.e. root of all user account
-// Example: ou=People,dc=example,dc=com
-define('LDAP_ACCOUNT_BASE', '');
+// LDAP DN for users
+// Example for ActiveDirectory: CN=Users,DC=kanboard,DC=local
+// Example for OpenLDAP: ou=People,dc=example,dc=com
+define('LDAP_USER_BASE_DN', '');
-// LDAP query pattern to use when searching for a user account
+// LDAP pattern to use when searching for a user account
// Example for ActiveDirectory: '(&(objectClass=user)(sAMAccountName=%s))'
// Example for OpenLDAP: 'uid=%s'
-define('LDAP_USER_PATTERN', '');
-
-// Name of an attribute of the user account object which should be used as the full name of the user
-define('LDAP_ACCOUNT_FULLNAME', 'displayname');
-
-// Name of an attribute of the user account object which should be used as the email of the user
-define('LDAP_ACCOUNT_EMAIL', 'mail');
+define('LDAP_USER_FILTER', '');
-// Name of an attribute of the user account object which should be used as the id of the user. (optional)
+// LDAP attribute for username
// Example for ActiveDirectory: 'samaccountname'
// Example for OpenLDAP: 'uid'
-define('LDAP_ACCOUNT_ID', '');
-
-// LDAP Attribute for group membership
-define('LDAP_ACCOUNT_MEMBEROF', 'memberof');
-
-// DN for administrators
-// Example: CN=Kanboard Admins,CN=Users,DC=kanboard,DC=local
-define('LDAP_GROUP_ADMIN_DN', '');
+define('LDAP_USER_ATTRIBUTE_USERNAME', 'uid');
-// DN for project administrators
-// Example: CN=Kanboard Project Admins,CN=Users,DC=kanboard,DC=local
-define('LDAP_GROUP_PROJECT_ADMIN_DN', '');
+// LDAP attribute for user full name
+// Example for ActiveDirectory: 'displayname'
+// Example for OpenLDAP: 'cn'
+define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn');
-// By default Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive)
-// Set to true if you want to preserve the case
-define('LDAP_USERNAME_CASE_SENSITIVE', false);
-
-// Automatically create user account
-define('LDAP_ACCOUNT_CREATION', true);
-
-// Enable/disable Google authentication
-define('GOOGLE_AUTH', false);
-
-// Google client id (Get this value from the Google developer console)
-define('GOOGLE_CLIENT_ID', '');
-
-// Google client secret key (Get this value from the Google developer console)
-define('GOOGLE_CLIENT_SECRET', '');
+// LDAP attribute for user email
+define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail');
-// Enable/disable GitHub authentication
-define('GITHUB_AUTH', false);
+// LDAP attribute to find groups in user profile
+define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof');
-// GitHub client id (Copy it from your settings -> Applications -> Developer applications)
-define('GITHUB_CLIENT_ID', '');
+// Allow automatic LDAP user creation
+define('LDAP_USER_CREATION', true);
-// GitHub client secret key (Copy it from your settings -> Applications -> Developer applications)
-define('GITHUB_CLIENT_SECRET', '');
-
-// Github oauth2 authorize url
-define('GITHUB_OAUTH_AUTHORIZE_URL', 'https://github.com/login/oauth/authorize');
-
-// Github oauth2 token url
-define('GITHUB_OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token');
-
-// Github API url (don't forget the trailing slash)
-define('GITHUB_API_URL', 'https://api.github.com/');
-
-// Enable/disable Gitlab authentication
-define('GITLAB_AUTH', false);
+// LDAP DN for administrators
+// Example: CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local
+define('LDAP_GROUP_ADMIN_DN', '');
-// Gitlab application id
-define('GITLAB_CLIENT_ID', '');
+// LDAP DN for managers
+// Example: CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local
+define('LDAP_GROUP_MANAGER_DN', '');
-// Gitlab application secret
-define('GITLAB_CLIENT_SECRET', '');
+// Enable LDAP group provider for project permissions
+// The end-user will be able to browse LDAP groups from the user interface and allow access to specified projects
+define('LDAP_GROUP_PROVIDER', false);
-// Gitlab oauth2 authorize url
-define('GITLAB_OAUTH_AUTHORIZE_URL', 'https://gitlab.com/oauth/authorize');
+// LDAP Base DN for groups
+define('LDAP_GROUP_BASE_DN', '');
-// Gitlab oauth2 token url
-define('GITLAB_OAUTH_TOKEN_URL', 'https://gitlab.com/oauth/token');
+// LDAP group filter
+// Example for ActiveDirectory: (&(objectClass=group)(sAMAccountName=%s*))
+define('LDAP_GROUP_FILTER', '');
-// Gitlab API url endpoint (don't forget the trailing slash)
-define('GITLAB_API_URL', 'https://gitlab.com/api/v3/');
+// LDAP attribute for the group name
+define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn');
// Enable/disable the reverse proxy authentication
define('REVERSE_PROXY_AUTH', false);
@@ -207,6 +166,9 @@ define('ENABLE_URL_REWRITE', false);
// Hide login form, useful if all your users use Google/Github/ReverseProxy authentication
define('HIDE_LOGIN_FORM', false);
+// Disabling logout (for external SSO authentication)
+define('DISABLE_LOGOUT', false);
+
// Enable captcha after 3 authentication failure
define('BRUTEFORCE_CAPTCHA', 3);
diff --git a/doc/2fa.markdown b/doc/2fa.markdown
index 627c636a..1085f621 100644
--- a/doc/2fa.markdown
+++ b/doc/2fa.markdown
@@ -1,21 +1,21 @@
-Two factor authentication
+Two-Factor Authentication
=========================
-Each user can enable the [two factor authentication](http://en.wikipedia.org/wiki/Two_factor_authentication).
-After a successful login, a one-time code (6 characters) is asked to the user to allow the access to Kanboard.
+Each user can enable the [two-factor authentication](http://en.wikipedia.org/wiki/Two_factor_authentication).
+After a successful login, a one-time code (6 characters) is asked to the user to allow access to Kanboard.
-This code have to be provided by a compatible software generally installed on your smartphone.
+This code has to be provided by a compatible software generally installed on your smartphone.
Kanboard use the [Time-based One-time Password Algorithm](http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm) defined in the [RFC 6238](http://tools.ietf.org/html/rfc6238).
There are many software compatible with the standard TOTP system.
-By example, you can use these free and open source applications:
+For example, you can use these free and open source applications:
- [Google Authenticator](https://github.com/google/google-authenticator/) (Android, iOS, Blackberry)
- [FreeOTP](https://fedorahosted.org/freeotp/) (Android, iOS)
- [OATH Toolkit](http://www.nongnu.org/oath-toolkit/) (Command line utility on Unix/Linux)
-This system can work offline and you don't necessary need to have a mobile phone.
+This system can work offline and you don't necessarily need to have a mobile phone.
Setup
-----
diff --git a/doc/analytics-tasks.markdown b/doc/analytics-tasks.markdown
index c5acea5f..4a49c154 100644
--- a/doc/analytics-tasks.markdown
+++ b/doc/analytics-tasks.markdown
@@ -1,7 +1,7 @@
Analytics for tasks
===================
-Each task have an analytics section available from the left menu in the task view.
+Each task has an analytics section available from the left menu in the task view.
Lead and cycle time
-------------------
@@ -13,12 +13,12 @@ Lead and cycle time
- If the task is not closed the current time is used instead of the date of completion.
- If the start date is not specified, the cycle time is not calculated.
-Note: You can configure an automatic action to define automatically the start date when you move a task to the column of your choice.
+Note: You can configure an automatic action to define the start date automatically when you move a task to the column of your choice.
Time spent into each column
---------------------------
![Time spent into each column](http://kanboard.net/screenshots/documentation/time-into-each-column.png)
-- This chart show the total time spent into each column for the task.
+- This chart shows the total time spent into each column for the task.
- The time spent is calculated until the task is closed.
diff --git a/doc/analytics.markdown b/doc/analytics.markdown
index 4ae9572f..d72fc383 100644
--- a/doc/analytics.markdown
+++ b/doc/analytics.markdown
@@ -1,7 +1,7 @@
Analytics
=========
-Each project have an analytics section. Depending how you are using Kanboard, you can see those reports:
+Each project have an analytics section. Depending on how you are using Kanboard, you can see those reports:
User repartition
----------------
@@ -22,21 +22,21 @@ Cumulative flow diagram
![Cumulative flow diagram](http://kanboard.net/screenshots/documentation/cfd.png)
-- This chart show the number of tasks cumulatively for each column over the time.
-- Everyday, the total number of tasks is recorded for each column.
+- This chart shows the number of tasks cumulatively for each column over the time.
+- Every day, the total number of tasks is recorded for each column.
- If you would like to exclude closed tasks, change the [global project settings](project-configuration.markdown).
-Note: You need to have at least 2 days of data to see the graph.
+Note: You need to have at least two days of data to see the graph.
-Burndown chart
---------------
+Burn down chart
+---------------
![Burndown chart](http://kanboard.net/screenshots/documentation/burndown-chart.png)
The [burn down chart](http://en.wikipedia.org/wiki/Burn_down_chart) is available for each project.
- This chart is a graphical representation of work left to do versus time.
-- Kanboard use the complexity or story point to generate this diagram.
+- Kanboard use the complexity or story point to generate this diagram.
- Everyday, the sum of the story points for each column is calculated.
Average time spent into each column
@@ -44,9 +44,9 @@ Average time spent into each column
![Average time spent into each column](http://kanboard.net/screenshots/documentation/average-time-spent-into-each-column.png)
-This chart show the average time spent into each column for the last 1000 tasks.
+This chart shows the average time spent into each column for the last 1000 tasks.
-- Kanboard use the task transitions to calculate the data.
+- Kanboard uses the task transitions to calculate the data.
- The time spent is calculated until the task is closed.
Average Lead and Cycle time
@@ -54,17 +54,12 @@ Average Lead and Cycle time
![Average time spent into each column](http://kanboard.net/screenshots/documentation/average-lead-cycle-time.png)
-This chart show the average lead and cycle time for the last 1000 tasks over the time.
+This chart show the average lead and cycle time for the last 1000 tasks over time.
- The lead time is the time between the task creation and the date of completion.
-- The cycle time is time between the specified start date of the task to completion date.
+- The cycle time is time between the specified start date of the task to the completion date.
- If the task is not closed, the current time is used instead of the date of completion.
-Those metrics are calculated and recorded everyday for the whole project.
+Those metrics are calculated and recorded every day for the whole project.
-Don't forget to run the daily job for stats calculation
--------------------------------------------------------
-
-To generate accurate analytics data, you should run the daily cronjob **project daily statistics**.
-
-[Read the documentation about Kanboard CLI](cli.markdown)
+Note: Don't forget to run the [daily cronjob](cronjob.markdown) to have accurate statistics.
diff --git a/doc/api-action-procedures.markdown b/doc/api-action-procedures.markdown
new file mode 100644
index 00000000..377ca56a
--- /dev/null
+++ b/doc/api-action-procedures.markdown
@@ -0,0 +1,245 @@
+API Automatic Actions Procedures
+================================
+
+## getAvailableActions
+
+- Purpose: **Get list of available automatic actions**
+- Parameters: none
+- Result on success: **list of actions**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAvailableActions",
+ "id": 1217735483
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1217735483,
+ "result": {
+ "\Kanboard\Action\TaskLogMoveAnotherColumn": "Add a comment logging moving the task between columns",
+ "\Kanboard\Action\TaskAssignColorUser": "Assign a color to a specific user",
+ "\Kanboard\Action\TaskAssignColorColumn": "Assign a color when the task is moved to a specific column",
+ "\Kanboard\Action\TaskAssignCategoryColor": "Assign automatically a category based on a color",
+ "\Kanboard\Action\TaskAssignColorCategory": "Assign automatically a color based on a category",
+ "\Kanboard\Action\TaskAssignSpecificUser": "Assign the task to a specific user",
+ "\Kanboard\Action\TaskAssignCurrentUser": "Assign the task to the person who does the action",
+ "\Kanboard\Action\TaskUpdateStartDate": "Automatically update the start date",
+ "\Kanboard\Action\TaskAssignUser": "Change the assignee based on an external username",
+ "\Kanboard\Action\TaskAssignCategoryLabel": "Change the category based on an external label",
+ "\Kanboard\Action\TaskClose": "Close a task",
+ "\Kanboard\Action\CommentCreation": "Create a comment from an external provider",
+ "\Kanboard\Action\TaskCreation": "Create a task from an external provider",
+ "\Kanboard\Action\TaskDuplicateAnotherProject": "Duplicate the task to another project",
+ "\Kanboard\Action\TaskMoveColumnAssigned": "Move the task to another column when assigned to a user",
+ "\Kanboard\Action\TaskMoveColumnUnAssigned": "Move the task to another column when assignee is cleared",
+ "\Kanboard\Action\TaskMoveAnotherProject": "Move the task to another project",
+ "\Kanboard\Action\TaskOpen": "Open a task"
+ }
+}
+```
+
+## getAvailableActionEvents
+
+- Purpose: **Get list of available events for actions**
+- Parameters: none
+- Result on success: **list of events**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAvailableActionEvents",
+ "id": 2116665643
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2116665643,
+ "result": {
+ "bitbucket.webhook.commit": "Bitbucket commit received",
+ "task.close": "Closing a task",
+ "github.webhook.commit": "Github commit received",
+ "github.webhook.issue.assignee": "Github issue assignee change",
+ "github.webhook.issue.closed": "Github issue closed",
+ "github.webhook.issue.commented": "Github issue comment created",
+ "github.webhook.issue.label": "Github issue label change",
+ "github.webhook.issue.opened": "Github issue opened",
+ "github.webhook.issue.reopened": "Github issue reopened",
+ "gitlab.webhook.commit": "Gitlab commit received",
+ "gitlab.webhook.issue.closed": "Gitlab issue closed",
+ "gitlab.webhook.issue.opened": "Gitlab issue opened",
+ "task.move.column": "Move a task to another column",
+ "task.open": "Open a closed task",
+ "task.assignee_change": "Task assignee change",
+ "task.create": "Task creation",
+ "task.create_update": "Task creation or modification",
+ "task.update": "Task modification"
+ }
+}
+```
+
+## getCompatibleActionEvents
+
+- Purpose: **Get list of events compatible with an action**
+- Parameters:
+ - **action_name** (string, required)
+- Result on success: **list of events**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getCompatibleActionEvents",
+ "id": 899370297,
+ "params": [
+ "\Kanboard\Action\TaskClose"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 899370297,
+ "result": {
+ "bitbucket.webhook.commit": "Bitbucket commit received",
+ "github.webhook.commit": "Github commit received",
+ "github.webhook.issue.closed": "Github issue closed",
+ "gitlab.webhook.commit": "Gitlab commit received",
+ "gitlab.webhook.issue.closed": "Gitlab issue closed",
+ "task.move.column": "Move a task to another column"
+ }
+}
+```
+
+## getActions
+
+- Purpose: **Get list of actions for a project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **list of actions properties**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getActions",
+ "id": 1433237746,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1433237746,
+ "result": [
+ {
+ "id" : "13",
+ "project_id" : "2",
+ "event_name" : "task.move.column",
+ "action_name" : "\Kanboard\Action\TaskAssignSpecificUser",
+ "params" : {
+ "column_id" : "5",
+ "user_id" : "1"
+ }
+ }
+ ]
+}
+```
+
+## createAction
+
+- Purpose: **Create an action**
+- Parameters:
+ - **project_id** (integer, required)
+ - **event_name** (string, required)
+ - **action_name** (string, required)
+ - **params** (key/value parameters, required)
+- Result on success: **action_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createAction",
+ "id": 1433237746,
+ "params": {
+ "project_id" : "2",
+ "event_name" : "task.move.column",
+ "action_name" : "\Kanboard\Action\TaskAssignSpecificUser",
+ "params" : {
+ "column_id" : "3",
+ "user_id" : "2"
+ }
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1433237746,
+ "result": 14
+}
+```
+
+## removeAction
+
+- Purpose: **Remove an action**
+- Parameters:
+ - **action_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeAction",
+ "id": 1510741671,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1510741671,
+ "result": true
+}
+```
diff --git a/doc/api-application-procedures.markdown b/doc/api-application-procedures.markdown
new file mode 100644
index 00000000..08474559
--- /dev/null
+++ b/doc/api-application-procedures.markdown
@@ -0,0 +1,291 @@
+API Application Procedures
+==========================
+
+## getVersion
+
+- Purpose: **Get the application version**
+- Parameters: none
+- Result: **version** (Example: 1.0.12, master)
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getVersion",
+ "id": 1661138292
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1661138292,
+ "result": "1.0.13"
+}
+```
+
+## getTimezone
+
+- Purpose: **Get the application timezone**
+- Parameters: none
+- Result on success: **Timezone** (Example: UTC, Europe/Paris)
+- Result on failure: **Default timezone** (UTC)
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getTimezone",
+ "id": 1661138292
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1661138292,
+ "result": "Europe\/Paris"
+}
+```
+
+## getDefaultTaskColors
+
+- Purpose: **Get all default task colors**
+- Parameters: None
+- Result on success: **Color properties**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getDefaultTaskColors",
+ "id": 2108929212
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2108929212,
+ "result": {
+ "yellow": {
+ "name": "Yellow",
+ "background": "rgb(245, 247, 196)",
+ "border": "rgb(223, 227, 45)"
+ },
+ "blue": {
+ "name": "Blue",
+ "background": "rgb(219, 235, 255)",
+ "border": "rgb(168, 207, 255)"
+ },
+ "green": {
+ "name": "Green",
+ "background": "rgb(189, 244, 203)",
+ "border": "rgb(74, 227, 113)"
+ },
+ "purple": {
+ "name": "Purple",
+ "background": "rgb(223, 176, 255)",
+ "border": "rgb(205, 133, 254)"
+ },
+ "red": {
+ "name": "Red",
+ "background": "rgb(255, 187, 187)",
+ "border": "rgb(255, 151, 151)"
+ },
+ "orange": {
+ "name": "Orange",
+ "background": "rgb(255, 215, 179)",
+ "border": "rgb(255, 172, 98)"
+ },
+ "grey": {
+ "name": "Grey",
+ "background": "rgb(238, 238, 238)",
+ "border": "rgb(204, 204, 204)"
+ },
+ "brown": {
+ "name": "Brown",
+ "background": "#d7ccc8",
+ "border": "#4e342e"
+ },
+ "deep_orange": {
+ "name": "Deep Orange",
+ "background": "#ffab91",
+ "border": "#e64a19"
+ },
+ "dark_grey": {
+ "name": "Dark Grey",
+ "background": "#cfd8dc",
+ "border": "#455a64"
+ },
+ "pink": {
+ "name": "Pink",
+ "background": "#f48fb1",
+ "border": "#d81b60"
+ },
+ "teal": {
+ "name": "Teal",
+ "background": "#80cbc4",
+ "border": "#00695c"
+ },
+ "cyan": {
+ "name": "Cyan",
+ "background": "#b2ebf2",
+ "border": "#00bcd4"
+ },
+ "lime": {
+ "name": "Lime",
+ "background": "#e6ee9c",
+ "border": "#afb42b"
+ },
+ "light_green": {
+ "name": "Light Green",
+ "background": "#dcedc8",
+ "border": "#689f38"
+ },
+ "amber": {
+ "name": "Amber",
+ "background": "#ffe082",
+ "border": "#ffa000"
+ }
+ }
+}
+```
+
+## getDefaultTaskColor
+
+- Purpose: **Get default task color**
+- Parameters: None
+- Result on success: **color_id**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getDefaultTaskColor",
+ "id": 1144775215
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1144775215,
+ "result": "yellow"
+}
+```
+
+## getColorList
+
+- Purpose: **Get the list of task colors**
+- Parameters: none
+- Result on success: **Dictionary of color_id => color_name**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getColorList",
+ "id": 1677051386
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1677051386,
+ "result": {
+ "yellow": "Yellow",
+ "blue": "Blue",
+ "green": "Green",
+ "purple": "Purple",
+ "red": "Red",
+ "orange": "Orange",
+ "grey": "Grey",
+ "brown": "Brown",
+ "deep_orange": "Deep Orange",
+ "dark_grey": "Dark Grey",
+ "pink": "Pink",
+ "teal": "Teal",
+ "cyan": "Cyan",
+ "lime": "Lime",
+ "light_green": "Light Green",
+ "amber": "Amber"
+ }
+}
+```
+
+## getApplicationRoles
+
+- Purpose: **Get the application roles**
+- Parameters: none
+- Result: **Dictionary of role => role_name**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getApplicationRoles",
+ "id": 317154243
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 317154243,
+ "result": {
+ "app-admin": "Administrator",
+ "app-manager": "Manager",
+ "app-user": "User"
+ }
+}
+```
+
+## getProjectRoles
+
+- Purpose: **Get the project roles**
+- Parameters: none
+- Result: **Dictionary of role => role_name**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getProjectRoles",
+ "id": 8981960
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 8981960,
+ "result": {
+ "project-manager": "Project Manager",
+ "project-member": "Project Member",
+ "project-viewer": "Project Viewer"
+ }
+}
+``` \ No newline at end of file
diff --git a/doc/api-authentication.markdown b/doc/api-authentication.markdown
new file mode 100644
index 00000000..962e5b1b
--- /dev/null
+++ b/doc/api-authentication.markdown
@@ -0,0 +1,66 @@
+API Authentication
+==================
+
+Default method (HTTP Basic)
+---------------------------
+
+The API credentials are available on the settings page.
+
+- API end-point: `https://YOUR_SERVER/jsonrpc.php`
+
+If you want to use the "application api":
+
+- Username: `jsonrpc`
+- Password: API token on the settings page
+
+Otherwise for the "user api", just use the real username/passsword.
+
+The API use the [HTTP Basic Authentication Scheme described in the RFC2617](http://www.ietf.org/rfc/rfc2617.txt).
+If there is an authentication error, you will receive the HTTP status code `401 Not Authorized`.
+
+### Authorized User API procedures
+
+- getMe
+- getMyDashboard
+- getMyActivityStream
+- createMyPrivateProject
+- getMyProjectsList
+- getMyProjects
+- getTimezone
+- getVersion
+- getDefaultTaskColor
+- getDefaultTaskColors
+- getColorList
+- getProjectById
+- getTask
+- getTaskByReference
+- getAllTasks
+- openTask
+- closeTask
+- moveTaskPosition
+- createTask
+- updateTask
+- getBoard
+- getProjectActivity
+- getMyOverdueTasks
+
+Custom HTTP header
+------------------
+
+You can use an alternative HTTP header for the authentication if your server have a very specific configuration.
+
+- The header name can be anything you want, by example `X-API-Auth`.
+- The header value is the `username:password` encoded in Base64.
+
+Configuration:
+
+1. Define your custom header in your `config.php`: `define('API_AUTHENTICATION_HEADER', 'X-API-Auth');`
+2. Encode the credentials in Base64, example with PHP `base64_encode('jsonrpc:19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929');`
+3. Test with curl:
+
+```bash
+curl \
+-H 'X-API-Auth: anNvbnJwYzoxOWZmZDk3MDlkMDNjZTUwNjc1YzNhNDNkMWM0OWMxYWMyMDdmNGJjNDVmMDZjNWIyNzAxZmJkZjg5Mjk=' \
+-d '{"jsonrpc": "2.0", "method": "getAllProjects", "id": 1}' \
+http://localhost/kanboard/jsonrpc.php
+```
diff --git a/doc/api-board-procedures.markdown b/doc/api-board-procedures.markdown
new file mode 100644
index 00000000..6f8a878e
--- /dev/null
+++ b/doc/api-board-procedures.markdown
@@ -0,0 +1,158 @@
+API Board Procedures
+====================
+
+## getBoard
+
+- Purpose: **Get all necessary information to display a board**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **board properties**
+- Result on failure: **empty list**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getBoard",
+ "id": 827046470,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 827046470,
+ "result": [
+ {
+ "id": 0,
+ "name": "Default swimlane",
+ "columns": [
+ {
+ "id": "1",
+ "title": "Backlog",
+ "position": "1",
+ "project_id": "1",
+ "task_limit": "0",
+ "description": "",
+ "tasks": [],
+ "nb_tasks": 0,
+ "score": 0
+ },
+ {
+ "id": "2",
+ "title": "Ready",
+ "position": "2",
+ "project_id": "1",
+ "task_limit": "0",
+ "description": "",
+ "tasks": [
+ {
+ "nb_comments":"0",
+ "nb_files":"0",
+ "nb_subtasks":"0",
+ "nb_completed_subtasks":"0",
+ "nb_links":"0",
+ "id":"2",
+ "reference":"",
+ "title":"Test",
+ "description":"",
+ "date_creation":"1430870507",
+ "date_modification":"1430870507",
+ "date_completed":null,
+ "date_due":"0",
+ "color_id":"yellow",
+ "project_id":"1",
+ "column_id":"2",
+ "swimlane_id":"0",
+ "owner_id":"0",
+ "creator_id":"1",
+ "position":"1",
+ "is_active":"1",
+ "score":"0",
+ "category_id":"0",
+ "date_moved":"1430870507",
+ "recurrence_status":"0",
+ "recurrence_trigger":"0",
+ "recurrence_factor":"0",
+ "recurrence_timeframe":"0",
+ "recurrence_basedate":"0",
+ "recurrence_parent":null,
+ "recurrence_child":null,
+ "assignee_username":null,
+ "assignee_name":null
+ }
+ ],
+ "nb_tasks": 1,
+ "score": 0
+ },
+ {
+ "id": "3",
+ "title": "Work in progress",
+ "position": "3",
+ "project_id": "1",
+ "task_limit": "0",
+ "description": "",
+ "tasks": [
+ {
+ "nb_comments":"0",
+ "nb_files":"0",
+ "nb_subtasks":"1",
+ "nb_completed_subtasks":"0",
+ "nb_links":"0",
+ "id":"1",
+ "reference":"",
+ "title":"Task with comment",
+ "description":"",
+ "date_creation":"1430783188",
+ "date_modification":"1430783188",
+ "date_completed":null,
+ "date_due":"0",
+ "color_id":"red",
+ "project_id":"1",
+ "column_id":"3",
+ "swimlane_id":"0",
+ "owner_id":"1",
+ "creator_id":"0",
+ "position":"1",
+ "is_active":"1",
+ "score":"0",
+ "category_id":"0",
+ "date_moved":"1430783191",
+ "recurrence_status":"0",
+ "recurrence_trigger":"0",
+ "recurrence_factor":"0",
+ "recurrence_timeframe":"0",
+ "recurrence_basedate":"0",
+ "recurrence_parent":null,
+ "recurrence_child":null,
+ "assignee_username":"admin",
+ "assignee_name":null
+ }
+ ],
+ "nb_tasks": 1,
+ "score": 0
+ },
+ {
+ "id": "4",
+ "title": "Done",
+ "position": "4",
+ "project_id": "1",
+ "task_limit": "0",
+ "description": "",
+ "tasks": [],
+ "nb_tasks": 0,
+ "score": 0
+ }
+ ],
+ "nb_columns": 4,
+ "nb_tasks": 2
+ }
+ ]
+}
+```
diff --git a/doc/api-category-procedures.markdown b/doc/api-category-procedures.markdown
new file mode 100644
index 00000000..644c09c6
--- /dev/null
+++ b/doc/api-category-procedures.markdown
@@ -0,0 +1,172 @@
+API Category Procedures
+=======================
+
+## createCategory
+
+- Purpose: **Create a new category**
+- Parameters:
+- **project_id** (integer, required)
+ - **name** (string, required, must be unique for the given project)
+- Result on success: **category_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createCategory",
+ "id": 541909890,
+ "params": {
+ "name": "Super category",
+ "project_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 541909890,
+ "result": 4
+}
+```
+
+## getCategory
+
+- Purpose: **Get category information**
+- Parameters:
+ - **category_id** (integer, required)
+- Result on success: **category properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getCategory",
+ "id": 203539163,
+ "params": {
+ "category_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+
+ "jsonrpc": "2.0",
+ "id": 203539163,
+ "result": {
+ "id": "1",
+ "name": "Super category",
+ "project_id": "1"
+ }
+}
+```
+
+## getAllCategories
+
+- Purpose: **Get all available categories**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **List of categories**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllCategories",
+ "id": 1261777968,
+ "params": {
+ "project_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1261777968,
+ "result": [
+ {
+ "id": "1",
+ "name": "Super category",
+ "project_id": "1"
+ }
+ ]
+}
+```
+
+## updateCategory
+
+- Purpose: **Update a category**
+- Parameters:
+ - **id** (integer, required)
+ - **name** (string, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateCategory",
+ "id": 570195391,
+ "params": {
+ "id": 1,
+ "name": "Renamed category"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 570195391,
+ "result": true
+}
+```
+
+## removeCategory
+
+- Purpose: **Remove a category**
+- Parameters:
+ - **category_id** (integer)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeCategory",
+ "id": 88225706,
+ "params": {
+ "category_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 88225706,
+ "result": true
+}
+```
diff --git a/doc/api-column-procedures.markdown b/doc/api-column-procedures.markdown
new file mode 100644
index 00000000..c5d2793b
--- /dev/null
+++ b/doc/api-column-procedures.markdown
@@ -0,0 +1,229 @@
+API Column Procedures
+=====================
+
+## getColumns
+
+- Purpose: **Get all columns information for a given project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **columns properties**
+- Result on failure: **empty list**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getColumns",
+ "id": 887036325,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 887036325,
+ "result": [
+ {
+ "id": "1",
+ "title": "Backlog",
+ "position": "1",
+ "project_id": "1",
+ "task_limit": "0"
+ },
+ {
+ "id": "2",
+ "title": "Ready",
+ "position": "2",
+ "project_id": "1",
+ "task_limit": "0"
+ },
+ {
+ "id": "3",
+ "title": "Work in progress",
+ "position": "3",
+ "project_id": "1",
+ "task_limit": "0"
+ }
+ ]
+}
+```
+
+## getColumn
+
+- Purpose: **Get a single column**
+- Parameters:
+ - **column_id** (integer, required)
+- Result on success: **column properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getColumn",
+ "id": 1242049935,
+ "params": [
+ 2
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1242049935,
+ "result": {
+ "id": "2",
+ "title": "Youpi",
+ "position": "2",
+ "project_id": "1",
+ "task_limit": "5"
+ }
+}
+```
+
+## changeColumnPosition
+
+- Purpose: **Change the column position**
+- Parameters:
+ - **project_id** (integer, required)
+ - **column_id** (integer, required)
+ - **position** (integer, required, must be >= 1)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "changeColumnPosition",
+ "id": 99275573,
+ "params": [
+ 1,
+ 2,
+ 3
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 99275573,
+ "result": true
+}
+```
+
+## updateColumn
+
+- Purpose: **Update column properties**
+- Parameters:
+ - **column_id** (integer, required)
+ - **title** (string, required)
+ - **task_limit** (integer, optional)
+ - **description** (string, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateColumn",
+ "id": 480740641,
+ "params": [
+ 2,
+ "Boo",
+ 5
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 480740641,
+ "result": true
+}
+```
+
+## addColumn
+
+- Purpose: **Add a new column**
+- Parameters:
+ - **project_id** (integer, required)
+ - **title** (string, required)
+ - **task_limit** (integer, optional)
+ - **description** (string, optional)
+- Result on success: **column_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "addColumn",
+ "id": 638544704,
+ "params": [
+ 1,
+ "Boo"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 638544704,
+ "result": 5
+}
+```
+
+## removeColumn
+
+- Purpose: **Remove a column**
+- Parameters:
+ - **column_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeColumn",
+ "id": 1433237746,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1433237746,
+ "result": true
+}
+```
diff --git a/doc/api-comment-procedures.markdown b/doc/api-comment-procedures.markdown
new file mode 100644
index 00000000..5ac25b13
--- /dev/null
+++ b/doc/api-comment-procedures.markdown
@@ -0,0 +1,182 @@
+API Comment Procedures
+======================
+
+## createComment
+
+- Purpose: **Create a new comment**
+- Parameters:
+ - **task_id** (integer, required)
+ - **user_id** (integer, required)
+ - **content** Markdown content (string, required)
+- Result on success: **comment_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createComment",
+ "id": 1580417921,
+ "params": {
+ "task_id": 1,
+ "user_id": 1,
+ "content": "Comment #1"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1580417921,
+ "result": 11
+}
+```
+
+## getComment
+
+- Purpose: **Get comment information**
+- Parameters:
+ - **comment_id** (integer, required)
+- Result on success: **comment properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getComment",
+ "id": 867839500,
+ "params": {
+ "comment_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 867839500,
+ "result": {
+ "id": "1",
+ "task_id": "1",
+ "user_id": "1",
+ "date_creation": "1410881970",
+ "comment": "Comment #1",
+ "username": "admin",
+ "name": null
+ }
+}
+```
+
+## getAllComments
+
+- Purpose: **Get all available comments**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **List of comments**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllComments",
+ "id": 148484683,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 148484683,
+ "result": [
+ {
+ "id": "1",
+ "date_creation": "1410882272",
+ "task_id": "1",
+ "user_id": "1",
+ "comment": "Comment #1",
+ "username": "admin",
+ "name": null
+ },
+ ...
+ ]
+}
+```
+
+## updateComment
+
+- Purpose: **Update a comment**
+- Parameters:
+ - **id** (integer, required)
+ - **content** Markdown content (string, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateComment",
+ "id": 496470023,
+ "params": {
+ "id": 1,
+ "content": "Comment #1 updated"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1493368950,
+ "result": true
+}
+```
+
+## removeComment
+
+- Purpose: **Remove a comment**
+- Parameters:
+ - **comment_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeComment",
+ "id": 328836871,
+ "params": {
+ "comment_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 328836871,
+ "result": true
+}
+```
diff --git a/doc/api-examples.markdown b/doc/api-examples.markdown
new file mode 100644
index 00000000..14d5db98
--- /dev/null
+++ b/doc/api-examples.markdown
@@ -0,0 +1,152 @@
+API Examples
+============
+
+Example with cURL
+-----------------
+
+From the command line:
+
+```bash
+curl \
+-u "jsonrpc:19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929" \
+-d '{"jsonrpc": "2.0", "method": "getAllProjects", "id": 1}' \
+http://localhost/kanboard/jsonrpc.php
+```
+
+Response from the server:
+
+```json
+{
+ "jsonrpc":"2.0",
+ "id":1,
+ "result":[
+ {
+ "id":"1",
+ "name":"API test",
+ "is_active":"1",
+ "token":"6bd0932fe7f4b5e6e4bc3c72800bfdef36a2c5de2f38f756dfb5bd632ebf",
+ "last_modified":"1403392631"
+ }
+ ]
+}
+```
+
+Example with Python
+-------------------
+
+You can use the [official Python client for Kanboard](https://github.com/kanboard/kanboard-api-python):
+
+```bash
+pip install kanboard
+```
+
+Here an example to create a project and a task:
+
+```python
+from kanboard import Kanboard
+
+kb = Kanboard("http://localhost/jsonrpc.php", "jsonrpc", "your_api_token")
+
+project_id = kb.create_project(name="My project")
+
+task_id = kb.create_task(project_id=project_id, title="My task title")
+```
+
+There are more examples on the [official website](https://github.com/kanboard/kanboard-api-python).
+
+Example with a PHP client
+-------------------------
+
+You can use this [Json-RPC Client/Server library for PHP](https://github.com/fguillot/JsonRPC), here an example:
+
+```php
+<?php
+
+$client = new JsonRPC\Client('http://localhost:8000/jsonrpc.php');
+$client->authentication('jsonrpc', '19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929');
+
+print_r($client->getAllProjects());
+
+```
+
+The response:
+
+```
+Array
+(
+ [0] => Array
+ (
+ [id] => 1
+ [name] => API test
+ [is_active] => 1
+ [token] => 6bd0932fe7f4b5e6e4bc3c72800bfdef36a2c5de2f38f756dfb5bd632ebf
+ [last_modified] => 1403392631
+ )
+
+)
+```
+
+Example with Ruby
+-----------------
+
+This example can be used with Kanboard configured with Reverse-Proxy authentication and the API configured with a custom authentication header:
+
+```ruby
+require 'faraday'
+
+conn = Faraday.new(:url => 'https://kanboard.example.com') do |faraday|
+ faraday.response :logger
+ faraday.headers['X-API-Auth'] = 'XXX' # base64_encode('jsonrpc:API_KEY')
+ faraday.basic_auth(ENV['user'], ENV['pw']) # user/pass to get through basic auth
+ faraday.adapter Faraday.default_adapter # make requests with Net::HTTP
+end
+
+response = conn.post do |req|
+ req.url '/jsonrpc.php'
+ req.headers['Content-Type'] = 'application/json'
+ req.body = '{ "jsonrpc": "2.0", "id": 1, "method": "getAllProjects" }'
+end
+
+puts response.body
+```
+
+
+Example with Java
+-----------------
+
+This is a basic example using Spring. For proper usage see [this link](http://spring.io/guides/gs/consuming-rest).
+
+```java
+import java.io.UnsupportedEncodingException;
+import java.util.Base64;
+
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.client.RestTemplate;
+
+public class ProjectService {
+
+ public void getAllProjects() throws UnsupportedEncodingException {
+
+ RestTemplate restTemplate = new RestTemplate();
+
+ String url = "http://localhost/kanboard/jsonrpc.php";
+ String requestJson = "{\"jsonrpc\": \"2.0\", \"method\": \"getAllProjects\", \"id\": 1}";
+ String user = "jsonrpc";
+ String apiToken = "19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929";
+
+ // encode api token
+ byte[] xApiAuthTokenBytes = String.join(":", user, apiToken).getBytes("utf-8");
+ String xApiAuthToken = Base64.getEncoder().encodeToString(xApiAuthTokenBytes);
+
+ // consume request
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("X-API-Auth", xApiAuthToken);
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity<String> entity = new HttpEntity<String>(requestJson, headers);
+ String answer = restTemplate.postForObject(url, entity, String.class);
+ System.out.println(answer);
+ }
+}
+```
diff --git a/doc/api-file-procedures.markdown b/doc/api-file-procedures.markdown
new file mode 100644
index 00000000..930be733
--- /dev/null
+++ b/doc/api-file-procedures.markdown
@@ -0,0 +1,217 @@
+API File Procedures
+===================
+
+## createTaskFile
+
+- Purpose: **Create and upload a new task attachment**
+- Parameters:
+ - **project_id** (integer, required)
+ - **task_id** (integer, required)
+ - **filename** (integer, required)
+ - **blob** File content encoded in base64 (string, required)
+- 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**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createTaskFile",
+ "id": 94500810,
+ "params": [
+ 1,
+ 1,
+ "My file",
+ "cGxhaW4gdGV4dCBmaWxl"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 94500810,
+ "result": 1
+}
+```
+
+## getAllTaskFiles
+
+- Purpose: **Get all files attached to task**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **list of files**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllTaskFiles",
+ "id": 1880662820,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1880662820,
+ "result": [
+ {
+ "id": "1",
+ "name": "My file",
+ "path": "1\/1\/0db4d0a897a4c852f6e12f0239d4805f7b4ab596",
+ "is_image": "0",
+ "task_id": "1",
+ "date": "1432509941",
+ "user_id": "0",
+ "size": "15",
+ "username": null,
+ "user_name": null
+ }
+ ]
+}
+```
+
+## getTaskFile
+
+- Purpose: **Get file information**
+- Parameters:
+ - **file_id** (integer, required)
+- Result on success: **file properties**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getTaskFile",
+ "id": 318676852,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 318676852,
+ "result": {
+ "id": "1",
+ "name": "My file",
+ "path": "1\/1\/0db4d0a897a4c852f6e12f0239d4805f7b4ab596",
+ "is_image": "0",
+ "task_id": "1",
+ "date": "1432509941",
+ "user_id": "0",
+ "size": "15"
+ }
+}
+```
+
+## downloadTaskFile
+
+- Purpose: **Download file contents (encoded in base64)**
+- Parameters:
+ - **file_id** (integer, required)
+- Result on success: **base64 encoded string**
+- Result on failure: **empty string**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "downloadTaskFile",
+ "id": 235943344,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 235943344,
+ "result": "cGxhaW4gdGV4dCBmaWxl"
+}
+```
+
+## removeTaskFile
+
+- Purpose: **Remove file**
+- Parameters:
+ - **file_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeTaskFile",
+ "id": 447036524,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 447036524,
+ "result": true
+}
+```
+
+## removeAllTaskFiles
+
+- 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": "removeAllTaskFiles",
+ "id": 593312993,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 593312993,
+ "result": true
+}
+```
diff --git a/doc/api-group-member-procedures.markdown b/doc/api-group-member-procedures.markdown
new file mode 100644
index 00000000..c4889265
--- /dev/null
+++ b/doc/api-group-member-procedures.markdown
@@ -0,0 +1,152 @@
+Group Member API Procedures
+===========================
+
+## getGroupMembers
+
+- Purpose: **Get all members of a group**
+- Parameters:
+ - **group_id** (integer, required)
+- Result on success: **List of users**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getGroupMembers",
+ "id": 1987176726,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1987176726,
+ "result": [
+ {
+ "group_id": "1",
+ "user_id": "1",
+ "id": "1",
+ "username": "admin",
+ "is_ldap_user": "0",
+ "name": null,
+ "email": null,
+ "notifications_enabled": "0",
+ "timezone": null,
+ "language": null,
+ "disable_login_form": "0",
+ "notifications_filter": "4",
+ "nb_failed_login": "0",
+ "lock_expiration_date": "0",
+ "is_project_admin": "0",
+ "gitlab_id": null,
+ "role": "app-admin"
+ }
+ ]
+}
+```
+
+## addGroupMember
+
+- Purpose: **Add a user to a group**
+- Parameters:
+ - **group_id** (integer, required)
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "addGroupMember",
+ "id": 1589058273,
+ "params": [
+ 1,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1589058273,
+ "result": true
+}
+```
+
+## removeGroupMember
+
+- Purpose: **Remove a user from a group**
+- Parameters:
+ - **group_id** (integer, required)
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeGroupMember",
+ "id": 1730416406,
+ "params": [
+ 1,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1730416406,
+ "result": true
+}
+```
+
+## isGroupMember
+
+- Purpose: **Check if a user is member of a group**
+- Parameters:
+ - **group_id** (integer, required)
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "isGroupMember",
+ "id": 1052800865,
+ "params": [
+ 1,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1052800865,
+ "result": false
+}
+```
diff --git a/doc/api-group-procedures.markdown b/doc/api-group-procedures.markdown
new file mode 100644
index 00000000..cb11fb96
--- /dev/null
+++ b/doc/api-group-procedures.markdown
@@ -0,0 +1,174 @@
+Group API Procedures
+====================
+
+## createGroup
+
+- Purpose: **Create a new group**
+- Parameters:
+ - **name** (string, required)
+ - **external_id** (string, optional)
+- Result on success: **link_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createGroup",
+ "id": 1416806551,
+ "params": [
+ "My Group B",
+ "1234"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1416806551,
+ "result": 2
+}
+```
+
+## updateGroup
+
+- Purpose: **Update a group**
+- Parameters:
+ - **group_id** (integer, required)
+ - **name** (string, optional)
+ - **external_id** (string, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateGroup",
+ "id": 866078030,
+ "params": {
+ "group_id": "1",
+ "name": "ABC",
+ "external_id": "something"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 866078030,
+ "result": true
+}
+```
+
+## removeGroup
+
+- Purpose: **Remove a group**
+- Parameters:
+ - **group_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeGroup",
+ "id": 566000661,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 566000661,
+ "result": true
+}
+```
+
+## getGroup
+
+- Purpose: **Get one group**
+- Parameters:
+ - **group_id** (integer, required)
+- Result on success: **Group dictionary**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getGroup",
+ "id": 1968647622,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1968647622,
+ "result": {
+ "id": "1",
+ "external_id": "",
+ "name": "My Group A"
+ }
+}
+```
+
+## getAllGroups
+
+- Purpose: **Get all groups**
+- Parameters: none
+- Result on success: **list of groups**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllGroups",
+ "id": 546070742
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 546070742,
+ "result": [
+ {
+ "id": "1",
+ "external_id": "",
+ "name": "My Group A"
+ },
+ {
+ "id": "2",
+ "external_id": "1234",
+ "name": "My Group B"
+ }
+ ]
+}
+```
diff --git a/doc/api-json-rpc.markdown b/doc/api-json-rpc.markdown
index 359f8b05..bb14b008 100644
--- a/doc/api-json-rpc.markdown
+++ b/doc/api-json-rpc.markdown
@@ -43,4511 +43,25 @@ You must call the API with a `POST` HTTP request.
Kanboard support batch requests, so you can make multiple API calls in a single HTTP request. It's particularly useful for mobile clients with higher network latency.
-Authentication
---------------
-
-### Default method (HTTP Basic)
-
-The API credentials are available on the settings page.
-
-- API end-point: `https://YOUR_SERVER/jsonrpc.php`
-
-If you want to use the "application api":
-
-- Username: `jsonrpc`
-- Password: API token on the settings page
-
-Otherwise for the "user api", just use the real username/passsword.
-
-The API use the [HTTP Basic Authentication Scheme described in the RFC2617](http://www.ietf.org/rfc/rfc2617.txt).
-If there is an authentication error, you will receive the HTTP status code `401 Not Authorized`.
-
-### Authorized User API procedures
-
-- getMe
-- getMyDashboard
-- getMyActivityStream
-- createMyPrivateProject
-- getMyProjectsList
-- getMyProjects
-- getTimezone
-- getVersion
-- getDefaultTaskColor
-- getDefaultTaskColors
-- getColorList
-- getProjectById
-- getTask
-- getTaskByReference
-- getAllTasks
-- openTask
-- closeTask
-- moveTaskPosition
-- createTask
-- updateTask
-- getBoard
-- getProjectActivity
-- getMyOverdueTasks
-
-### Custom HTTP header
-
-You can use an alternative HTTP header for the authentication if your server have a very specific configuration.
-
-- The header name can be anything you want, by example `X-API-Auth`.
-- The header value is the `username:password` encoded in Base64.
-
-Configuration:
-
-1. Define your custom header in your `config.php`: `define('API_AUTHENTICATION_HEADER', 'X-API-Auth');`
-2. Encode the credentials in Base64, example with PHP `base64_encode('jsonrpc:19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929');`
-3. Test with curl:
-
-```bash
-curl \
--H 'X-API-Auth: anNvbnJwYzoxOWZmZDk3MDlkMDNjZTUwNjc1YzNhNDNkMWM0OWMxYWMyMDdmNGJjNDVmMDZjNWIyNzAxZmJkZjg5Mjk=' \
--d '{"jsonrpc": "2.0", "method": "getAllProjects", "id": 1}' \
-http://localhost/kanboard/jsonrpc.php
-```
-
-Examples
---------
-
-### Example with cURL
-
-From the command line:
-
-```bash
-curl \
--u "jsonrpc:19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929" \
--d '{"jsonrpc": "2.0", "method": "getAllProjects", "id": 1}' \
-http://localhost/kanboard/jsonrpc.php
-```
-
-Response from the server:
-
-```json
-{
- "jsonrpc":"2.0",
- "id":1,
- "result":[
- {
- "id":"1",
- "name":"API test",
- "is_active":"1",
- "token":"6bd0932fe7f4b5e6e4bc3c72800bfdef36a2c5de2f38f756dfb5bd632ebf",
- "last_modified":"1403392631"
- }
- ]
-}
-```
-
-### Example with Python
-
-Here a basic example written in Python to create a task:
-
-```python
-#!/usr/bin/env python
-
-import requests
-import json
-
-def main():
- url = "http://demo.kanboard.net/jsonrpc.php"
- api_key = "be4271664ca8169d32af49d8e1ec854edb0290bc3588a2e356275eab9505"
- headers = {"content-type": "application/json"}
-
- payload = {
- "method": "createTask",
- "params": {
- "title": "Python API test",
- "project_id": 1
- },
- "jsonrpc": "2.0",
- "id": 1,
- }
-
- response = requests.post(
- url,
- data=json.dumps(payload),
- headers=headers,
- auth=("jsonrpc", api_key)
- )
-
- if response.status_code == 401:
- print "Authentication failed"
- else:
- result = response.json()
-
- assert result["result"] == True
- assert result["jsonrpc"]
- assert result["id"] == 1
-
- print "Task created successfully!"
-
-if __name__ == "__main__":
- main()
-```
-
-Run this script from your terminal:
-
-```bash
-python jsonrpc.py
-Task created successfully!
-```
-
-### Example with a PHP client:
-
-I wrote a simple [Json-RPC Client/Server library in PHP](https://github.com/fguillot/JsonRPC), here an example:
-
-```php
-<?php
-
-$client = new JsonRPC\Client('http://localhost:8000/jsonrpc.php');
-$client->authentication('jsonrpc', '19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929');
-
-print_r($client->getAllProjects());
-
-```
-
-The response:
-
-```
-Array
-(
- [0] => Array
- (
- [id] => 1
- [name] => API test
- [is_active] => 1
- [token] => 6bd0932fe7f4b5e6e4bc3c72800bfdef36a2c5de2f38f756dfb5bd632ebf
- [last_modified] => 1403392631
- )
-
-)
-```
-
-### Example with Ruby
-
-This example can be used with Kanboard configured with Reverse-Proxy authentication and the API configured with a custom authentication header:
-
-```ruby
-require 'faraday'
-
-conn = Faraday.new(:url => 'https://kanboard.example.com') do |faraday|
- faraday.response :logger
- faraday.headers['X-API-Auth'] = 'XXX' # base64_encode('jsonrpc:API_KEY')
- faraday.basic_auth(ENV['user'], ENV['pw']) # user/pass to get through basic auth
- faraday.adapter Faraday.default_adapter # make requests with Net::HTTP
-end
-
-response = conn.post do |req|
- req.url '/jsonrpc.php'
- req.headers['Content-Type'] = 'application/json'
- req.body = '{ "jsonrpc": "2.0", "id": 1, "method": "getAllProjects" }'
-end
-
-puts response.body
-```
-
-
-### Example with Java
-
-This is a basic example using Spring. For proper usage see [this link](http://spring.io/guides/gs/consuming-rest).
-
-```java
-import java.io.UnsupportedEncodingException;
-import java.util.Base64;
-
-import org.springframework.http.HttpEntity;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.web.client.RestTemplate;
-
-public class ProjectService {
-
- public void getAllProjects() throws UnsupportedEncodingException {
-
- RestTemplate restTemplate = new RestTemplate();
-
- String url = "http://localhost/kanboard/jsonrpc.php";
- String requestJson = "{\"jsonrpc\": \"2.0\", \"method\": \"getAllProjects\", \"id\": 1}";
- String user = "jsonrpc";
- String apiToken = "19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929";
-
- // encode api token
- byte[] xApiAuthTokenBytes = String.join(":", user, apiToken).getBytes("utf-8");
- String xApiAuthToken = Base64.getEncoder().encodeToString(xApiAuthTokenBytes);
-
- // consume request
- HttpHeaders headers = new HttpHeaders();
- headers.add("X-API-Auth", xApiAuthToken);
- headers.setContentType(MediaType.APPLICATION_JSON);
- HttpEntity<String> entity = new HttpEntity<String>(requestJson, headers);
- String answer = restTemplate.postForObject(url, entity, String.class);
- System.out.println(answer);
- }
-}
-```
-
-Procedures
-----------
-
-### getVersion
-
-- Purpose: **Get the application version**
-- Parameters: none
-- Result: **version** (Example: 1.0.12, master)
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getVersion",
- "id": 1661138292
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1661138292,
- "result": "1.0.13"
-}
-```
-
-### getTimezone
-
-- Purpose: **Get the application timezone**
-- Parameters: none
-- Result on success: **Timezone** (Example: UTC, Europe/Paris)
-- Result on failure: **Default timezone** (UTC)
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getTimezone",
- "id": 1661138292
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1661138292,
- "result": "Europe\/Paris"
-}
-```
-
-### getDefaultTaskColors
-
-- Purpose: **Get all default task colors**
-- Parameters: None
-- Result on success: **Color properties**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getDefaultTaskColors",
- "id": 2108929212
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2108929212,
- "result": {
- "yellow": {
- "name": "Yellow",
- "background": "rgb(245, 247, 196)",
- "border": "rgb(223, 227, 45)"
- },
- "blue": {
- "name": "Blue",
- "background": "rgb(219, 235, 255)",
- "border": "rgb(168, 207, 255)"
- },
- "green": {
- "name": "Green",
- "background": "rgb(189, 244, 203)",
- "border": "rgb(74, 227, 113)"
- },
- "purple": {
- "name": "Purple",
- "background": "rgb(223, 176, 255)",
- "border": "rgb(205, 133, 254)"
- },
- "red": {
- "name": "Red",
- "background": "rgb(255, 187, 187)",
- "border": "rgb(255, 151, 151)"
- },
- "orange": {
- "name": "Orange",
- "background": "rgb(255, 215, 179)",
- "border": "rgb(255, 172, 98)"
- },
- "grey": {
- "name": "Grey",
- "background": "rgb(238, 238, 238)",
- "border": "rgb(204, 204, 204)"
- },
- "brown": {
- "name": "Brown",
- "background": "#d7ccc8",
- "border": "#4e342e"
- },
- "deep_orange": {
- "name": "Deep Orange",
- "background": "#ffab91",
- "border": "#e64a19"
- },
- "dark_grey": {
- "name": "Dark Grey",
- "background": "#cfd8dc",
- "border": "#455a64"
- },
- "pink": {
- "name": "Pink",
- "background": "#f48fb1",
- "border": "#d81b60"
- },
- "teal": {
- "name": "Teal",
- "background": "#80cbc4",
- "border": "#00695c"
- },
- "cyan": {
- "name": "Cyan",
- "background": "#b2ebf2",
- "border": "#00bcd4"
- },
- "lime": {
- "name": "Lime",
- "background": "#e6ee9c",
- "border": "#afb42b"
- },
- "light_green": {
- "name": "Light Green",
- "background": "#dcedc8",
- "border": "#689f38"
- },
- "amber": {
- "name": "Amber",
- "background": "#ffe082",
- "border": "#ffa000"
- }
- }
-}
-```
-
-### getDefaultTaskColor
-
-- Purpose: **Get default task color**
-- Parameters: None
-- Result on success: **color_id**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getDefaultTaskColor",
- "id": 1144775215
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1144775215,
- "result": "yellow"
-}
-```
-
-### getColorList
-
-- Purpose: **Get the list of task colors**
-- Parameters: none
-- Result on success: **Dictionary of color_id => color_name**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getColorList",
- "id": 1677051386
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1677051386,
- "result": {
- "yellow": "Yellow",
- "blue": "Blue",
- "green": "Green",
- "purple": "Purple",
- "red": "Red",
- "orange": "Orange",
- "grey": "Grey",
- "brown": "Brown",
- "deep_orange": "Deep Orange",
- "dark_grey": "Dark Grey",
- "pink": "Pink",
- "teal": "Teal",
- "cyan": "Cyan",
- "lime": "Lime",
- "light_green": "Light Green",
- "amber": "Amber"
- }
-}
-```
-
-### createProject
-
-- Purpose: **Create a new project**
-- Parameters:
- - **name** (string, required)
- - **description** (string, optional)
-- Result on success: **project_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createProject",
- "id": 1797076613,
- "params": {
- "name": "PHP client"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1797076613,
- "result": 2
-}
-```
-
-### getProjectById
-
-- Purpose: **Get project information**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **project properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getProjectById",
- "id": 226760253,
- "params": {
- "project_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 226760253,
- "result": {
- "id": "1",
- "name": "API test",
- "is_active": "1",
- "token": "",
- "last_modified": "1436119135",
- "is_public": "0",
- "is_private": "0",
- "is_everybody_allowed": "0",
- "default_swimlane": "Default swimlane",
- "show_default_swimlane": "1",
- "description": "test",
- "identifier": "",
- "url": {
- "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
- "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
- "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
- }
- }
-}
-```
-
-### getProjectByName
-
-- Purpose: **Get project information**
-- Parameters:
- - **name** (string, required)
-- Result on success: **project properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getProjectByName",
- "id": 1620253806,
- "params": {
- "name": "Test"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1620253806,
- "result": {
- "id": "1",
- "name": "Test",
- "is_active": "1",
- "token": "",
- "last_modified": "1436119135",
- "is_public": "0",
- "is_private": "0",
- "is_everybody_allowed": "0",
- "default_swimlane": "Default swimlane",
- "show_default_swimlane": "1",
- "description": "test",
- "identifier": "",
- "url": {
- "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
- "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
- "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
- }
- }
-}
-```
-
-### getAllProjects
-
-- Purpose: **Get all available projects**
-- Parameters:
- - **none**
-- Result on success: **List of projects**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllProjects",
- "id": 2134420212
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2134420212,
- "result": [
- {
- "id": "1",
- "name": "API test",
- "is_active": "1",
- "token": "",
- "last_modified": "1436119570",
- "is_public": "0",
- "is_private": "0",
- "is_everybody_allowed": "0",
- "default_swimlane": "Default swimlane",
- "show_default_swimlane": "1",
- "description": null,
- "identifier": "",
- "url": {
- "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
- "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
- "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
- }
- }
- ]
-}
-```
-
-### updateProject
-
-- Purpose: **Update a project**
-- Parameters:
- - **id** (integer, required)
- - **name** (string, required)
- - **description** (string, optional)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateProject",
- "id": 1853996288,
- "params": {
- "id": 1,
- "name": "PHP client update"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1853996288,
- "result": true
-}
-```
-
-### removeProject
-
-- Purpose: **Remove a project**
-- Parameters:
- **project_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeProject",
- "id": 46285125,
- "params": {
- "project_id": "2"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 46285125,
- "result": true
-}
-```
-
-### enableProject
-
-- Purpose: **Enable a project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "enableProject",
- "id": 1775494839,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1775494839,
- "result": true
-}
-```
-
-### disableProject
-
-- Purpose: **Disable a project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "disableProject",
- "id": 1734202312,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1734202312,
- "result": true
-}
-```
-
-### enableProjectPublicAccess
-
-- Purpose: **Enable public access for a given project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "enableProjectPublicAccess",
- "id": 103792571,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 103792571,
- "result": true
-}
-```
-
-### disableProjectPublicAccess
-
-- Purpose: **Disable public access for a given project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "disableProjectPublicAccess",
- "id": 942472945,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 942472945,
- "result": true
-}
-```
-
-### getProjectActivity
-
-- Purpose: **Get activity stream for a project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **List of events**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getProjectActivity",
- "id": 942472945,
- "params": [
- "project_id": 1
- ]
-}
-```
-
-### getProjectActivities
-
-- Purpose: **Get Activityfeed for Project(s)**
-- Parameters:
- - **project_ids** (integer array, required)
-- Result on success: **List of events**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getProjectActivities",
- "id": 942472945,
- "params": [
- "project_ids": [1,2]
- ]
-}
-```
-
-### getMembers
-
-- Purpose: **Get members of a project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: Key/value pair of user_id and username
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getMembers",
- "id": 1944388643,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1944388643,
- "result": {
- "1": "user1",
- "2": "user2",
- "3": "user3"
- }
-}
-```
-
-### revokeUser
-
-- Purpose: **Revoke user access for a given project**
-- Parameters:
- - **project_id** (integer, required)
- - **user_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "revokeUser",
- "id": 251218350,
- "params": [
- 1,
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 251218350,
- "result": true
-}
-```
-
-### allowUser
-
-- Purpose: **Grant user access for a given project**
-- Parameters:
- - **project_id** (integer, required)
- - **user_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "allowUser",
- "id": 2111451404,
- "params": [
- 1,
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2111451404,
- "result": true
-}
-```
-
-
-### getBoard
-
-- Purpose: **Get all necessary information to display a board**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **board properties**
-- Result on failure: **empty list**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getBoard",
- "id": 827046470,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 827046470,
- "result": [
- {
- "id": 0,
- "name": "Default swimlane",
- "columns": [
- {
- "id": "1",
- "title": "Backlog",
- "position": "1",
- "project_id": "1",
- "task_limit": "0",
- "description": "",
- "tasks": [],
- "nb_tasks": 0,
- "score": 0
- },
- {
- "id": "2",
- "title": "Ready",
- "position": "2",
- "project_id": "1",
- "task_limit": "0",
- "description": "",
- "tasks": [
- {
- "nb_comments":"0",
- "nb_files":"0",
- "nb_subtasks":"0",
- "nb_completed_subtasks":"0",
- "nb_links":"0",
- "id":"2",
- "reference":"",
- "title":"Test",
- "description":"",
- "date_creation":"1430870507",
- "date_modification":"1430870507",
- "date_completed":null,
- "date_due":"0",
- "color_id":"yellow",
- "project_id":"1",
- "column_id":"2",
- "swimlane_id":"0",
- "owner_id":"0",
- "creator_id":"1",
- "position":"1",
- "is_active":"1",
- "score":"0",
- "category_id":"0",
- "date_moved":"1430870507",
- "recurrence_status":"0",
- "recurrence_trigger":"0",
- "recurrence_factor":"0",
- "recurrence_timeframe":"0",
- "recurrence_basedate":"0",
- "recurrence_parent":null,
- "recurrence_child":null,
- "assignee_username":null,
- "assignee_name":null
- }
- ],
- "nb_tasks": 1,
- "score": 0
- },
- {
- "id": "3",
- "title": "Work in progress",
- "position": "3",
- "project_id": "1",
- "task_limit": "0",
- "description": "",
- "tasks": [
- {
- "nb_comments":"0",
- "nb_files":"0",
- "nb_subtasks":"1",
- "nb_completed_subtasks":"0",
- "nb_links":"0",
- "id":"1",
- "reference":"",
- "title":"Task with comment",
- "description":"",
- "date_creation":"1430783188",
- "date_modification":"1430783188",
- "date_completed":null,
- "date_due":"0",
- "color_id":"red",
- "project_id":"1",
- "column_id":"3",
- "swimlane_id":"0",
- "owner_id":"1",
- "creator_id":"0",
- "position":"1",
- "is_active":"1",
- "score":"0",
- "category_id":"0",
- "date_moved":"1430783191",
- "recurrence_status":"0",
- "recurrence_trigger":"0",
- "recurrence_factor":"0",
- "recurrence_timeframe":"0",
- "recurrence_basedate":"0",
- "recurrence_parent":null,
- "recurrence_child":null,
- "assignee_username":"admin",
- "assignee_name":null
- }
- ],
- "nb_tasks": 1,
- "score": 0
- },
- {
- "id": "4",
- "title": "Done",
- "position": "4",
- "project_id": "1",
- "task_limit": "0",
- "description": "",
- "tasks": [],
- "nb_tasks": 0,
- "score": 0
- }
- ],
- "nb_columns": 4,
- "nb_tasks": 2
- }
- ]
-}
-```
-
-### getColumns
-
-- Purpose: **Get all columns information for a given project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **columns properties**
-- Result on failure: **empty list**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getColumns",
- "id": 887036325,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 887036325,
- "result": [
- {
- "id": "1",
- "title": "Backlog",
- "position": "1",
- "project_id": "1",
- "task_limit": "0"
- },
- {
- "id": "2",
- "title": "Ready",
- "position": "2",
- "project_id": "1",
- "task_limit": "0"
- },
- {
- "id": "3",
- "title": "Work in progress",
- "position": "3",
- "project_id": "1",
- "task_limit": "0"
- }
- ]
-}
-```
-
-### getColumn
-
-- Purpose: **Get a single column**
-- Parameters:
- - **column_id** (integer, required)
-- Result on success: **column properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getColumn",
- "id": 1242049935,
- "params": [
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1242049935,
- "result": {
- "id": "2",
- "title": "Youpi",
- "position": "2",
- "project_id": "1",
- "task_limit": "5"
- }
-}
-```
-
-### moveColumnUp
-
-- Purpose: **Move up the column position**
-- Parameters:
- - **project_id** (integer, required)
- - **column_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "moveColumnUp",
- "id": 99275573,
- "params": [
- 1,
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 99275573,
- "result": true
-}
-```
-
-### moveColumnDown
-
-- Purpose: **Move down the column position**
-- Parameters:
- - **project_id** (integer, required)
- - **column_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "moveColumnDown",
- "id": 957090649,
- "params": {
- "project_id": 1,
- "column_id": 2
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 957090649,
- "result": true
-}
-```
-
-### updateColumn
-
-- Purpose: **Update column properties**
-- Parameters:
- - **column_id** (integer, required)
- - **title** (string, required)
- - **task_limit** (integer, optional)
- - **description** (string, optional)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateColumn",
- "id": 480740641,
- "params": [
- 2,
- "Boo",
- 5
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 480740641,
- "result": true
-}
-```
-
-### addColumn
-
-- Purpose: **Add a new column**
-- Parameters:
- - **project_id** (integer, required)
- - **title** (string, required)
- - **task_limit** (integer, optional)
- - **description** (string, optional)
-- Result on success: **column_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "addColumn",
- "id": 638544704,
- "params": [
- 1,
- "Boo"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 638544704,
- "result": 5
-}
-```
-
-### removeColumn
-
-- Purpose: **Remove a column**
-- Parameters:
- - **column_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeColumn",
- "id": 1433237746,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1433237746,
- "result": true
-}
-```
-
-### getDefaultSwimlane
-
-- Purpose: **Get the default swimlane for a project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getDefaultSwimlane",
- "id": 898774713,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 898774713,
- "result": {
- "id": "1",
- "default_swimlane": "Default swimlane",
- "show_default_swimlane": "1"
- }
-}
-```
-
-### getActiveSwimlanes
-
-- Purpose: **Get the list of enabled swimlanes of a project (include default swimlane if enabled)**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **List of swimlanes**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getActiveSwimlanes",
- "id": 934789422,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 934789422,
- "result": [
- {
- "id": 0,
- "name": "Default swimlane"
- },
- {
- "id": "2",
- "name": "Swimlane A"
- }
- ]
-}
-```
-
-### getAllSwimlanes
-
-- Purpose: **Get the list of all swimlanes of a project (enabled or disabled) and sorted by position**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **List of swimlanes**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllSwimlanes",
- "id": 509791576,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 509791576,
- "result": [
- {
- "id": "1",
- "name": "Another swimlane",
- "position": "1",
- "is_active": "1",
- "project_id": "1"
- },
- {
- "id": "2",
- "name": "Swimlane A",
- "position": "2",
- "is_active": "1",
- "project_id": "1"
- }
- ]
-}
-```
-
-### getSwimlane
-
-- Purpose: **Get the a swimlane by id**
-- Parameters:
- - **swimlane_id** (integer, required)
-- Result on success: **swimlane properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getSwimlane",
- "id": 131071870,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 131071870,
- "result": {
- "id": "1",
- "name": "Swimlane 1",
- "position": "1",
- "is_active": "1",
- "project_id": "1"
- }
-}
-```
-
-### getSwimlaneById
-
-- Purpose: **Get the a swimlane by id**
-- Parameters:
- - **swimlane_id** (integer, required)
-- Result on success: **swimlane properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getSwimlaneById",
- "id": 131071870,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 131071870,
- "result": {
- "id": "1",
- "name": "Swimlane 1",
- "position": "1",
- "is_active": "1",
- "project_id": "1"
- }
-}
-```
-
-### getSwimlaneByName
-
-- Purpose: **Get the a swimlane by name**
-- Parameters:
- - **project_id** (integer, required)
- - **name** (string, required)
-- Result on success: **swimlane properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getSwimlaneByName",
- "id": 824623567,
- "params": [
- 1,
- "Swimlane 1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 824623567,
- "result": {
- "id": "1",
- "name": "Swimlane 1",
- "position": "1",
- "is_active": "1",
- "project_id": "1"
- }
-}
-```
-
-### moveSwimlaneUp
-
-- Purpose: **Move up the swimlane position**
-- Parameters:
- - **project_id** (integer, required)
- - **swimlane_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "moveSwimlaneUp",
- "id": 99275573,
- "params": [
- 1,
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 99275573,
- "result": true
-}
-```
-
-### moveSwimlaneDown
-
-- Purpose: **Move down the swimlane position**
-- Parameters:
- - **project_id** (integer, required)
- - **swimlane_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "moveSwimlaneDown",
- "id": 957090649,
- "params": {
- "project_id": 1,
- "swimlane_id": 2
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 957090649,
- "result": true
-}
-```
-
-### updateSwimlane
-
-- Purpose: **Update swimlane properties**
-- Parameters:
- - **swimlane_id** (integer, required)
- - **name** (string, required)
- - **description** (string, optional)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateSwimlane",
- "id": 87102426,
- "params": [
- "1",
- "Another swimlane"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 87102426,
- "result": true
-}
-```
-
-### addSwimlane
-
-- Purpose: **Add a new swimlane**
-- Parameters:
- - **project_id** (integer, required)
- - **name** (string, required)
- - **description** (string, optional)
-- Result on success: **swimlane_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "addSwimlane",
- "id": 849940086,
- "params": [
- 1,
- "Swimlane 1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 849940086,
- "result": 1
-}
-```
-
-### removeSwimlane
-
-- Purpose: **Remove a swimlane**
-- Parameters:
- - **project_id** (integer, required)
- - **swimlane_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeSwimlane",
- "id": 1433237746,
- "params": [
- 2,
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1433237746,
- "result": true
-}
-```
-
-### disableSwimlane
-
-- Purpose: **Enable a swimlane**
-- Parameters:
- - **project_id** (integer, required)
- - **swimlane_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "disableSwimlane",
- "id": 1433237746,
- "params": [
- 2,
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1433237746,
- "result": true
-}
-```
-
-### enableSwimlane
-
-- Purpose: **Enable a swimlane**
-- Parameters:
- - **project_id** (integer, required)
- - **swimlane_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "enableSwimlane",
- "id": 1433237746,
- "params": [
- 2,
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1433237746,
- "result": true
-}
-```
-
-### getAvailableActions
-
-- Purpose: **Get list of available automatic actions**
-- Parameters: none
-- Result on success: **list of actions**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAvailableActions",
- "id": 1217735483
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1217735483,
- "result": {
- "TaskLogMoveAnotherColumn": "Add a comment logging moving the task between columns",
- "TaskAssignColorUser": "Assign a color to a specific user",
- "TaskAssignColorColumn": "Assign a color when the task is moved to a specific column",
- "TaskAssignCategoryColor": "Assign automatically a category based on a color",
- "TaskAssignColorCategory": "Assign automatically a color based on a category",
- "TaskAssignSpecificUser": "Assign the task to a specific user",
- "TaskAssignCurrentUser": "Assign the task to the person who does the action",
- "TaskUpdateStartDate": "Automatically update the start date",
- "TaskAssignUser": "Change the assignee based on an external username",
- "TaskAssignCategoryLabel": "Change the category based on an external label",
- "TaskClose": "Close a task",
- "CommentCreation": "Create a comment from an external provider",
- "TaskCreation": "Create a task from an external provider",
- "TaskDuplicateAnotherProject": "Duplicate the task to another project",
- "TaskMoveColumnAssigned": "Move the task to another column when assigned to a user",
- "TaskMoveColumnUnAssigned": "Move the task to another column when assignee is cleared",
- "TaskMoveAnotherProject": "Move the task to another project",
- "TaskOpen": "Open a task"
- }
-}
-```
-
-### getAvailableActionEvents
-
-- Purpose: **Get list of available events for actions**
-- Parameters: none
-- Result on success: **list of events**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAvailableActionEvents",
- "id": 2116665643
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2116665643,
- "result": {
- "bitbucket.webhook.commit": "Bitbucket commit received",
- "task.close": "Closing a task",
- "github.webhook.commit": "Github commit received",
- "github.webhook.issue.assignee": "Github issue assignee change",
- "github.webhook.issue.closed": "Github issue closed",
- "github.webhook.issue.commented": "Github issue comment created",
- "github.webhook.issue.label": "Github issue label change",
- "github.webhook.issue.opened": "Github issue opened",
- "github.webhook.issue.reopened": "Github issue reopened",
- "gitlab.webhook.commit": "Gitlab commit received",
- "gitlab.webhook.issue.closed": "Gitlab issue closed",
- "gitlab.webhook.issue.opened": "Gitlab issue opened",
- "task.move.column": "Move a task to another column",
- "task.open": "Open a closed task",
- "task.assignee_change": "Task assignee change",
- "task.create": "Task creation",
- "task.create_update": "Task creation or modification",
- "task.update": "Task modification"
- }
-}
-```
-
-### getCompatibleActionEvents
-
-- Purpose: **Get list of events compatible with an action**
-- Parameters:
- - **action_name** (string, required)
-- Result on success: **list of events**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getCompatibleActionEvents",
- "id": 899370297,
- "params": [
- "TaskClose"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 899370297,
- "result": {
- "bitbucket.webhook.commit": "Bitbucket commit received",
- "github.webhook.commit": "Github commit received",
- "github.webhook.issue.closed": "Github issue closed",
- "gitlab.webhook.commit": "Gitlab commit received",
- "gitlab.webhook.issue.closed": "Gitlab issue closed",
- "task.move.column": "Move a task to another column"
- }
-}
-```
-
-### getActions
-
-- Purpose: **Get list of actions for a project**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **list of actions properties**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getActions",
- "id": 1433237746,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1433237746,
- "result": [
- {
- "id" : "13",
- "project_id" : "2",
- "event_name" : "task.move.column",
- "action_name" : "TaskAssignSpecificUser",
- "params" : {
- "column_id" : "5",
- "user_id" : "1"
- }
- }
- ]
-}
-```
-
-### createAction
-
-- Purpose: **Create an action**
-- Parameters:
- - **project_id** (integer, required)
- - **event_name** (string, required)
- - **action_name** (string, required)
- - **params** (key/value parameters, required)
-- Result on success: **action_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createAction",
- "id": 1433237746,
- "params": {
- "project_id" : "2",
- "event_name" : "task.move.column",
- "action_name" : "TaskAssignSpecificUser",
- "params" : {
- "column_id" : "3",
- "user_id" : "2"
- }
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1433237746,
- "result": 14
-}
-```
-
-### removeAction
-
-- Purpose: **Remove an action**
-- Parameters:
- - **action_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeAction",
- "id": 1510741671,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1510741671,
- "result": true
-}
-```
-
-### createTask
-
-- Purpose: **Create a new task**
-- Parameters:
- - **title** (string, required)
- - **project_id** (integer, required)
- - **color_id** (string, optional)
- - **column_id** (integer, optional)
- - **owner_id** (integer, optional)
- - **creator_id** (integer, optional)
- - **date_due**: ISO8601 format (string, optional)
- - **description** Markdown content (string, optional)
- - **category_id** (integer, optional)
- - **score** (integer, optional)
- - **swimlane_id** (integer, optional)
- - **recurrence_status** (integer, optional)
- - **recurrence_trigger** (integer, optional)
- - **recurrence_factor** (integer, optional)
- - **recurrence_timeframe** (integer, optional)
- - **recurrence_basedate** (integer, optional)
-- Result on success: **task_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createTask",
- "id": 1176509098,
- "params": {
- "owner_id": 1,
- "creator_id": 0,
- "date_due": "",
- "description": "",
- "category_id": 0,
- "score": 0,
- "title": "Test",
- "project_id": 1,
- "color_id": "green",
- "column_id": 2,
- "recurrence_status": 0,
- "recurrence_trigger": 0,
- "recurrence_factor": 0,
- "recurrence_timeframe": 0,
- "recurrence_basedate": 0
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1176509098,
- "result": 3
-}
-```
-
-### getTask
-
-- Purpose: **Get task by the unique id**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **task properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getTask",
- "id": 700738119,
- "params": {
- "task_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 700738119,
- "result": {
- "id": "1",
- "title": "Task #1",
- "description": "",
- "date_creation": "1409963206",
- "color_id": "blue",
- "project_id": "1",
- "column_id": "2",
- "owner_id": "1",
- "position": "1",
- "is_active": "1",
- "date_completed": null,
- "score": "0",
- "date_due": "0",
- "category_id": "0",
- "creator_id": "0",
- "date_modification": "1409963206",
- "reference": "",
- "date_started": null,
- "time_spent": "0",
- "time_estimated": "0",
- "swimlane_id": "0",
- "date_moved": "1430875287",
- "recurrence_status": "0",
- "recurrence_trigger": "0",
- "recurrence_factor": "0",
- "recurrence_timeframe": "0",
- "recurrence_basedate": "0",
- "recurrence_parent": null,
- "recurrence_child": null,
- "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=1",
- "color": {
- "name": "Yellow",
- "background": "rgb(245, 247, 196)",
- "border": "rgb(223, 227, 45)"
- }
- }
-}
-```
-
-### 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,
- "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=5&project_id=1"
- }
-}
-```
-
-### getAllTasks
-
-- Purpose: **Get all available tasks**
-- Parameters:
- - **project_id** (integer, required)
- - **status_id**: The value 1 for active tasks and 0 for inactive (integer, required)
-- Result on success: **List of tasks**
-- Result on failure: **false**
-
-Request example to fetch all tasks on the board:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllTasks",
- "id": 133280317,
- "params": {
- "project_id": 1,
- "status_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 133280317,
- "result": [
- {
- "id": "1",
- "title": "Task #1",
- "description": "",
- "date_creation": "1409961789",
- "color_id": "blue",
- "project_id": "1",
- "column_id": "2",
- "owner_id": "1",
- "position": "1",
- "is_active": "1",
- "date_completed": null,
- "score": "0",
- "date_due": "0",
- "category_id": "0",
- "creator_id": "0",
- "date_modification": "1409961789",
- "reference": "",
- "date_started": null,
- "time_spent": "0",
- "time_estimated": "0",
- "swimlane_id": "0",
- "date_moved": "1430783191",
- "recurrence_status": "0",
- "recurrence_trigger": "0",
- "recurrence_factor": "0",
- "recurrence_timeframe": "0",
- "recurrence_basedate": "0",
- "recurrence_parent": null,
- "recurrence_child": null,
- "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=1"
- },
- {
- "id": "2",
- "title": "Test",
- "description": "",
- "date_creation": "1409962115",
- "color_id": "green",
- "project_id": "1",
- "column_id": "2",
- "owner_id": "1",
- "position": "2",
- "is_active": "1",
- "date_completed": null,
- "score": "0",
- "date_due": "0",
- "category_id": "0",
- "creator_id": "0",
- "date_modification": "1409962115",
- "reference": "",
- "date_started": null,
- "time_spent": "0",
- "time_estimated": "0",
- "swimlane_id": "0",
- "date_moved": "1430783191",
- "recurrence_status": "0",
- "recurrence_trigger": "0",
- "recurrence_factor": "0",
- "recurrence_timeframe": "0",
- "recurrence_basedate": "0",
- "recurrence_parent": null,
- "recurrence_child": null,
- "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=2&project_id=1"
- },
- ...
- ]
-}
-```
-
-### getOverdueTasks
-
-- Purpose: **Get all overdue tasks**
-- Result on success: **List of tasks**
-- Result on failure: **false**
-
-Request example to fetch all tasks on the board:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getOverdueTasks",
- "id": 133280317
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 133280317,
- "result": [
- {
- "id": "1",
- "title": "Task #1",
- "date_due": "1409961789",
- "project_id": "1",
- "project_name": "Test",
- "assignee_username":"admin",
- "assignee_name": null
- },
- {
- "id": "2",
- "title": "Test",
- "date_due": "1409962115",
- "project_id": "1",
- "project_name": "Test",
- "assignee_username":"admin",
- "assignee_name": null
- },
- ...
- ]
-}
-```
-
-### getOverdueTasksByProject
-
-- Purpose: **Get all overdue tasks for a special project**
-- Result on success: **List of tasks**
-- Result on failure: **false**
-
-Request example to fetch all tasks on the board:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getOverdueTasksByProject",
- "id": 133280317,
- "params": {
- "project_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 133280317,
- "result": [
- {
- "id": "1",
- "title": "Task #1",
- "date_due": "1409961789",
- "project_id": "1",
- "project_name": "Test",
- "assignee_username":"admin",
- "assignee_name": null
- },
- {
- "id": "2",
- "title": "Test",
- "date_due": "1409962115",
- "project_id": "1",
- "project_name": "Test",
- "assignee_username":"admin",
- "assignee_name": null
- },
- ...
- ]
-}
-```
-
-### updateTask
-
-- Purpose: **Update a task**
-- Parameters:
- - **id** (integer, required)
- - **title** (string, optional)
- - **project_id** (integer, optional)
- - **color_id** (string, optional)
- - **owner_id** (integer, optional)
- - **creator_id** (integer, optional)
- - **date_due**: ISO8601 format (string, optional)
- - **description** Markdown content (string, optional)
- - **category_id** (integer, optional)
- - **score** (integer, optional)
- - **recurrence_status** (integer, optional)
- - **recurrence_trigger** (integer, optional)
- - **recurrence_factor** (integer, optional)
- - **recurrence_timeframe** (integer, optional)
- - **recurrence_basedate** (integer, optional)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example to change the task color:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateTask",
- "id": 1406803059,
- "params": {
- "id": 1,
- "color_id": "blue"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1406803059,
- "result": true
-}
-```
-
-### openTask
-
-- Purpose: **Set a task to the status open**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "openTask",
- "id": 1888531925,
- "params": {
- "task_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1888531925,
- "result": true
-}
-```
-
-### closeTask
-
-- Purpose: **Set a task to the status close**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "closeTask",
- "id": 1654396960,
- "params": {
- "task_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1654396960,
- "result": true
-}
-```
-
-### removeTask
-
-- Purpose: **Remove a task**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeTask",
- "id": 1423501287,
- "params": {
- "task_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1423501287,
- "result": true
-}
-```
-
-### moveTaskPosition
-
-- Purpose: **Move a task to another column or another position**
-- Parameters:
- - **project_id** (integer, required)
- - **task_id** (integer, required)
- - **column_id** (integer, required)
- - **position** (integer, required)
- - **swimlane_id** (integer, optional, default=0)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "moveTaskPosition",
- "id": 117211800,
- "params": {
- "project_id": 1,
- "task_id": 1,
- "column_id": 2,
- "position": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 117211800,
- "result": true
-}
-```
-
-### createUser
-
-- Purpose: **Create a new user**
-- Parameters:
- - **username** Must be unique (string, required)
- - **password** Must have at least 6 characters (string, required)
- - **name** (string, optional)
- - **email** (string, optional)
- - **is_admin** Set the value 1 for admins or 0 for regular users (integer, optional)
- - **is_project_admin** Set the value 1 for project admins or 0 for regular users (integer, optional)
-- Result on success: **user_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createUser",
- "id": 1518863034,
- "params": {
- "username": "biloute",
- "password": "123456"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1518863034,
- "result": 22
-}
-```
-
-### createLdapUser
-
-- Purpose: **Create a new user authentified by LDAP**
-- Parameters:
- - **username** (string, optional if email is set)
- - **email** (string, optional if username is set)
- - **is_admin** Set the value 1 for admins or 0 for regular users (integer, optional)
- - **is_project_admin** Set the value 1 for project admins or 0 for regular users (integer, optional)
-- Result on success: **user_id**
-- Result on failure: **false**
-
-The user will only be created if a matching is found on the LDAP server.
-Username or email (or both) must be provided.
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createLdapUser",
- "id": 1518863034,
- "params": {
- "username": "biloute",
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1518863034,
- "result": 22
-}
-```
-
-### getUser
-
-- Purpose: **Get user information**
-- Parameters:
- - **user_id** (integer, required)
-- Result on success: **user properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getUser",
- "id": 1769674781,
- "params": {
- "user_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1769674781,
- "result": {
- "id": "1",
- "username": "biloute",
- "password": "$2y$10$dRs6pPoBu935RpmsrhmbjevJH5MgZ7Kr9QrnVINwwyZ3.MOwqg.0m",
- "is_admin": "0",
- "is_ldap_user": "0",
- "name": "",
- "email": "",
- "google_id": null,
- "github_id": null,
- "notifications_enabled": "0"
- }
-}
-```
-
-### getAllUsers
-
-- Purpose: **Get all available users**
-- Parameters:
- - **none**
-- Result on success: **List of users**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllUsers",
- "id": 1438712131
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1438712131,
- "result": [
- {
- "id": "1",
- "username": "biloute",
- "name": "",
- "email": "",
- "is_admin": "0",
- "is_ldap_user": "0",
- "notifications_enabled": "0",
- "google_id": null,
- "github_id": null
- },
- ...
- ]
-}
-```
-
-### updateUser
-
-- Purpose: **Update a user**
-- Parameters:
- - **id** (integer)
- - **username** (string, optional)
- - **name** (string, optional)
- - **email** (string, optional)
- - **is_admin** (integer, optional)
- - **is_project_admin** Set the value 1 for project admins or 0 for regular users (integer, optional)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateUser",
- "id": 322123657,
- "params": {
- "id": 1,
- "is_admin": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 322123657,
- "result": true
-}
-```
-
-### removeUser
-
-- Purpose: **Remove a user**
-- Parameters:
- - **user_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeUser",
- "id": 2094191872,
- "params": {
- "user_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2094191872,
- "result": true
-}
-```
-
-
-### createCategory
-
-- Purpose: **Create a new category**
-- Parameters:
-- **project_id** (integer, required)
- - **name** (string, required, must be unique for the given project)
-- Result on success: **category_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createCategory",
- "id": 541909890,
- "params": {
- "name": "Super category",
- "project_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 541909890,
- "result": 4
-}
-```
-
-### getCategory
-
-- Purpose: **Get category information**
-- Parameters:
- - **category_id** (integer, required)
-- Result on success: **category properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getCategory",
- "id": 203539163,
- "params": {
- "category_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
-
- "jsonrpc": "2.0",
- "id": 203539163,
- "result": {
- "id": "1",
- "name": "Super category",
- "project_id": "1"
- }
-}
-```
-
-### getAllCategories
-
-- Purpose: **Get all available categories**
-- Parameters:
- - **project_id** (integer, required)
-- Result on success: **List of categories**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllCategories",
- "id": 1261777968,
- "params": {
- "project_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1261777968,
- "result": [
- {
- "id": "1",
- "name": "Super category",
- "project_id": "1"
- }
- ]
-}
-```
-
-### updateCategory
-
-- Purpose: **Update a category**
-- Parameters:
- - **id** (integer, required)
- - **name** (string, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateCategory",
- "id": 570195391,
- "params": {
- "id": 1,
- "name": "Renamed category"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 570195391,
- "result": true
-}
-```
-
-### removeCategory
-
-- Purpose: **Remove a category**
-- Parameters:
- - **category_id** (integer)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeCategory",
- "id": 88225706,
- "params": {
- "category_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 88225706,
- "result": true
-}
-```
-
-
-### createComment
-
-- Purpose: **Create a new comment**
-- Parameters:
- - **task_id** (integer, required)
- - **user_id** (integer, required)
- - **content** Markdown content (string, required)
-- Result on success: **comment_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createComment",
- "id": 1580417921,
- "params": {
- "task_id": 1,
- "user_id": 1,
- "content": "Comment #1"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1580417921,
- "result": 11
-}
-```
-
-### getComment
-
-- Purpose: **Get comment information**
-- Parameters:
- - **comment_id** (integer, required)
-- Result on success: **comment properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getComment",
- "id": 867839500,
- "params": {
- "comment_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 867839500,
- "result": {
- "id": "1",
- "task_id": "1",
- "user_id": "1",
- "date_creation": "1410881970",
- "comment": "Comment #1",
- "username": "admin",
- "name": null
- }
-}
-```
-
-### getAllComments
-
-- Purpose: **Get all available comments**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **List of comments**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllComments",
- "id": 148484683,
- "params": {
- "task_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 148484683,
- "result": [
- {
- "id": "1",
- "date_creation": "1410882272",
- "task_id": "1",
- "user_id": "1",
- "comment": "Comment #1",
- "username": "admin",
- "name": null
- },
- ...
- ]
-}
-```
-
-### updateComment
-
-- Purpose: **Update a comment**
-- Parameters:
- - **id** (integer, required)
- - **content** Markdown content (string, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateComment",
- "id": 496470023,
- "params": {
- "id": 1,
- "content": "Comment #1 updated"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1493368950,
- "result": true
-}
-```
-
-### removeComment
-
-- Purpose: **Remove a comment**
-- Parameters:
- - **comment_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeComment",
- "id": 328836871,
- "params": {
- "comment_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 328836871,
- "result": true
-}
-```
-
-### createSubtask
-
-- Purpose: **Create a new subtask**
-- Parameters:
- - **task_id** (integer, required)
- - **title** (integer, required)
- - **user_id** (int, optional)
- - **time_estimated** (int, optional)
- - **time_spent** (int, optional)
- - **status** (int, optional)
-- Result on success: **subtask_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createSubtask",
- "id": 2041554661,
- "params": {
- "task_id": 1,
- "title": "Subtask #1"
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2041554661,
- "result": 45
-}
-```
-
-### getSubtask
-
-- Purpose: **Get subtask information**
-- Parameters:
- - **subtask_id** (integer)
-- Result on success: **subtask properties**
-- Result on failure: **null**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getSubtask",
- "id": 133184525,
- "params": {
- "subtask_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 133184525,
- "result": {
- "id": "1",
- "title": "Subtask #1",
- "status": "0",
- "time_estimated": "0",
- "time_spent": "0",
- "task_id": "1",
- "user_id": "0"
- }
-}
-```
-
-### getAllSubtasks
-
-- Purpose: **Get all available subtasks**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **List of subtasks**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllSubtasks",
- "id": 2087700490,
- "params": {
- "task_id": 1
- }
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2087700490,
- "result": [
- {
- "id": "1",
- "title": "Subtask #1",
- "status": "0",
- "time_estimated": "0",
- "time_spent": "0",
- "task_id": "1",
- "user_id": "0",
- "username": null,
- "name": null,
- "status_name": "Todo"
- },
- ...
- ]
-}
-```
-
-### updateSubtask
-
-- Purpose: **Update a subtask**
-- Parameters:
- - **id** (integer, required)
- - **task_id** (integer, required)
- - **title** (integer, optional)
- - **user_id** (integer, optional)
- - **time_estimated** (integer, optional)
- - **time_spent** (integer, optional)
- - **status** (integer, optional)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateSubtask",
- "id": 191749979,
- "params": {
- "id": 1,
- "task_id": 1,
- "status": 1,
- "time_spent": 5,
- "user_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 191749979,
- "result": true
-}
-```
-
-### removeSubtask
-
-- Purpose: **Remove a subtask**
-- Parameters:
- - **subtask_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeSubtask",
- "id": 1382487306,
- "params": {
- "subtask_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1382487306,
- "result": true
-}
-```
-
-### getAllLinks
-
-- Purpose: **Get the list of possible relations between tasks**
-- Parameters: none
-- Result on success: **List of links**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllLinks",
- "id": 113057196
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 113057196,
- "result": [
- {
- "id": "1",
- "label": "relates to",
- "opposite_id": "0"
- },
- {
- "id": "2",
- "label": "blocks",
- "opposite_id": "3"
- },
- {
- "id": "3",
- "label": "is blocked by",
- "opposite_id": "2"
- },
- {
- "id": "4",
- "label": "duplicates",
- "opposite_id": "5"
- },
- {
- "id": "5",
- "label": "is duplicated by",
- "opposite_id": "4"
- },
- {
- "id": "6",
- "label": "is a child of",
- "opposite_id": "7"
- },
- {
- "id": "7",
- "label": "is a parent of",
- "opposite_id": "6"
- },
- {
- "id": "8",
- "label": "targets milestone",
- "opposite_id": "9"
- },
- {
- "id": "9",
- "label": "is a milestone of",
- "opposite_id": "8"
- },
- {
- "id": "10",
- "label": "fixes",
- "opposite_id": "11"
- },
- {
- "id": "11",
- "label": "is fixed by",
- "opposite_id": "10"
- }
- ]
-}
-```
-
-### getOppositeLinkId
-
-- Purpose: **Get the opposite link id of a task link**
-- Parameters:
- - **link_id** (integer, required)
-- Result on success: **link_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getOppositeLinkId",
- "id": 407062448,
- "params": [
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 407062448,
- "result": "3"
-}
-```
-
-### getLinkByLabel
-
-- Purpose: **Get a link by label**
-- Parameters:
- - **label** (integer, required)
-- Result on success: **link properties**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getLinkByLabel",
- "id": 1796123316,
- "params": [
- "blocks"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1796123316,
- "result": {
- "id": "2",
- "label": "blocks",
- "opposite_id": "3"
- }
-}
-```
-
-### getLinkById
-
-- Purpose: **Get a link by id**
-- Parameters:
- - **link_id** (integer, required)
-- Result on success: **link properties**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getLinkById",
- "id": 1190238402,
- "params": [
- 4
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1190238402,
- "result": {
- "id": "4",
- "label": "duplicates",
- "opposite_id": "5"
- }
-}
-```
-
-### createLink
-
-- Purpose: **Create a new task relation**
-- Parameters:
- - **label** (integer, required)
- - **opposite_label** (integer, optional)
-- Result on success: **link_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createLink",
- "id": 1040237496,
- "params": [
- "foo",
- "bar"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1040237496,
- "result": 13
-}
-```
-
-### updateLink
-
-- Purpose: **Update a link**
-- Parameters:
- - **link_id** (integer, required)
- - **opposite_link_id** (integer, required)
- - **label** (string, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateLink",
- "id": 2110446926,
- "params": [
- "14",
- "12",
- "boo"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2110446926,
- "result": true
-}
-```
-
-### removeLink
-
-- Purpose: **Remove a link**
-- Parameters:
- - **link_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeLink",
- "id": 2136522739,
- "params": [
- "14"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2136522739,
- "result": true
-}
-```
-
-### createTaskLink
-
-- Purpose: **Create a link between two tasks**
-- Parameters:
- - **task_id** (integer, required)
- - **opposite_task_id** (integer, required)
- - **link_id** (integer, required)
-- Result on success: **task_link_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createTaskLink",
- "id": 509742912,
- "params": [
- 2,
- 3,
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 509742912,
- "result": 1
-}
-```
-
-### updateTaskLink
-
-- Purpose: **Update task link**
-- Parameters:
- - **task_link_id** (integer, required)
- - **task_id** (integer, required)
- - **opposite_task_id** (integer, required)
- - **link_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "updateTaskLink",
- "id": 669037109,
- "params": [
- 1,
- 2,
- 4,
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 669037109,
- "result": true
-}
-```
-
-### getTaskLinkById
-
-- Purpose: **Get a task link**
-- Parameters:
- - **task_link_id** (integer, required)
-- Result on success: **task link properties**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getTaskLinkById",
- "id": 809885202,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 809885202,
- "result": {
- "id": "1",
- "link_id": "1",
- "task_id": "2",
- "opposite_task_id": "3"
- }
-}
-```
-
-### getAllTaskLinks
-
-- Purpose: **Get all links related to a task**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **list of task link**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllTaskLinks",
- "id": 810848359,
- "params": [
- 2
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 810848359,
- "result": [
- {
- "id": "1",
- "task_id": "3",
- "label": "relates to",
- "title": "B",
- "is_active": "1",
- "project_id": "1",
- "task_time_spent": "0",
- "task_time_estimated": "0",
- "task_assignee_id": "0",
- "task_assignee_username": null,
- "task_assignee_name": null,
- "column_title": "Backlog"
- }
- ]
-}
-```
-
-### removeTaskLink
-
-- Purpose: **Remove a link between two tasks**
-- Parameters:
- - **task_link_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeTaskLink",
- "id": 473028226,
- "params": [
- 1
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 473028226,
- "result": true
-}
-```
-
-### createFile
-
-- Purpose: **Create and upload a new task attachment**
-- Parameters:
- - **project_id** (integer, required)
- - **task_id** (integer, required)
- - **filename** (integer, required)
- - **blob** File content encoded in base64 (string, required)
-- 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**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createFile",
- "id": 94500810,
- "params": [
- 1,
- 1,
- "My file",
- "cGxhaW4gdGV4dCBmaWxl"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 94500810,
- "result": 1
-}
-```
-
-### getAllFiles
-
-- Purpose: **Get all files attached to task**
-- Parameters:
- - **task_id** (integer, required)
-- Result on success: **list of files**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getAllFiles",
- "id": 1880662820,
- "params": {
- "task_id": 1
- }
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1880662820,
- "result": [
- {
- "id": "1",
- "name": "My file",
- "path": "1\/1\/0db4d0a897a4c852f6e12f0239d4805f7b4ab596",
- "is_image": "0",
- "task_id": "1",
- "date": "1432509941",
- "user_id": "0",
- "size": "15",
- "username": null,
- "user_name": null
- }
- ]
-}
-```
-
-### getFile
-
-- Purpose: **Get file information**
-- Parameters:
- - **file_id** (integer, required)
-- Result on success: **file properties**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getFile",
- "id": 318676852,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 318676852,
- "result": {
- "id": "1",
- "name": "My file",
- "path": "1\/1\/0db4d0a897a4c852f6e12f0239d4805f7b4ab596",
- "is_image": "0",
- "task_id": "1",
- "date": "1432509941",
- "user_id": "0",
- "size": "15"
- }
-}
-```
-
-### downloadFile
-
-- Purpose: **Download file contents (encoded in base64)**
-- Parameters:
- - **file_id** (integer, required)
-- Result on success: **base64 encoded string**
-- Result on failure: **empty string**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "downloadFile",
- "id": 235943344,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 235943344,
- "result": "cGxhaW4gdGV4dCBmaWxl"
-}
-```
-
-### removeFile
-
-- Purpose: **Remove file**
-- Parameters:
- - **file_id** (integer, required)
-- Result on success: **true**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "removeFile",
- "id": 447036524,
- "params": [
- "1"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 447036524,
- "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
-}
-```
-
-### getMe
-
-- Purpose: **Get logged user session**
-- Parameters: None
-- Result on success: **user session data**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getMe",
- "id": 1718627783
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1718627783,
- "result": {
- "id": 2,
- "username": "user",
- "is_admin": false,
- "is_ldap_user": false,
- "name": "",
- "email": "",
- "google_id": null,
- "github_id": null,
- "notifications_enabled": "0",
- "timezone": null,
- "language": null,
- "disable_login_form": "0",
- "twofactor_activated": false,
- "twofactor_secret": null,
- "token": "",
- "notifications_filter": "4"
- }
-}
-```
-
-### getMyDashboard
-
-- Purpose: **Get the dashboard of the logged user without pagination**
-- Parameters: None
-- Result on success: **Dashboard information**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getMyDashboard",
- "id": 447898718
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1563664593,
- "result": {
- "projects": [
- {
- "id": "2",
- "name": "my project",
- "is_active": "1",
- "token": "",
- "last_modified": "1438205337",
- "is_public": "0",
- "is_private": "1",
- "is_everybody_allowed": "0",
- "default_swimlane": "Default swimlane",
- "show_default_swimlane": "1",
- "description": null,
- "identifier": "",
- "columns": [
- {
- "id": "5",
- "title": "Backlog",
- "position": "1",
- "project_id": "2",
- "task_limit": "0",
- "description": "",
- "nb_tasks": 0
- },
- {
- "id": "6",
- "title": "Ready",
- "position": "2",
- "project_id": "2",
- "task_limit": "0",
- "description": "",
- "nb_tasks": 0
- },
- {
- "id": "7",
- "title": "Work in progress",
- "position": "3",
- "project_id": "2",
- "task_limit": "0",
- "description": "",
- "nb_tasks": 0
- },
- {
- "id": "8",
- "title": "Done",
- "position": "4",
- "project_id": "2",
- "task_limit": "0",
- "description": "",
- "nb_tasks": 0
- }
- ],
- "url": {
- "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=2",
- "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=2",
- "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=2"
- }
- }
- ],
- "tasks": [
- {
- "id": "1",
- "title": "new title",
- "date_due": "0",
- "date_creation": "1438205336",
- "project_id": "2",
- "color_id": "yellow",
- "time_spent": "0",
- "time_estimated": "0",
- "project_name": "my project",
- "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=2"
- }
- ],
- "subtasks": []
- }
-}
-```
-
-### getMyActivityStream
-
-- Purpose: **Get the last 100 events for the logged user**
-- Parameters: None
-- Result on success: **List of events**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getMyActivityStream",
- "id": 1132562181
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1132562181,
- "result": [
- {
- "id": "1",
- "date_creation": "1438205054",
- "event_name": "task.create",
- "creator_id": "2",
- "project_id": "2",
- "task_id": "1",
- "author_username": "user",
- "author_name": "",
- "email": "",
- "task": {
- "id": "1",
- "reference": "",
- "title": "my user title",
- "description": "",
- "date_creation": "1438205054",
- "date_completed": null,
- "date_modification": "1438205054",
- "date_due": "0",
- "date_started": null,
- "time_estimated": "0",
- "time_spent": "0",
- "color_id": "yellow",
- "project_id": "2",
- "column_id": "5",
- "owner_id": "0",
- "creator_id": "2",
- "position": "1",
- "is_active": "1",
- "score": "0",
- "category_id": "0",
- "swimlane_id": "0",
- "date_moved": "1438205054",
- "recurrence_status": "0",
- "recurrence_trigger": "0",
- "recurrence_factor": "0",
- "recurrence_timeframe": "0",
- "recurrence_basedate": "0",
- "recurrence_parent": null,
- "recurrence_child": null,
- "category_name": null,
- "swimlane_name": null,
- "project_name": "my project",
- "default_swimlane": "Default swimlane",
- "column_title": "Backlog",
- "assignee_username": null,
- "assignee_name": null,
- "creator_username": "user",
- "creator_name": ""
- },
- "changes": [],
- "author": "user",
- "event_title": "user created the task #1",
- "event_content": "\n<p class=\"activity-title\">\n user created the task <a href=\"\/?controller=task&amp;action=show&amp;task_id=1&amp;project_id=2\" class=\"\" title=\"\" >#1<\/a><\/p>\n<p class=\"activity-description\">\n <em>my user title<\/em>\n<\/p>"
- }
- ]
-}
-```
-
-### createMyPrivateProject
-
-- Purpose: **Create a private project for the logged user**
-- Parameters:
- - **name** (string, required)
- - **description** (string, optional)
-- Result on success: **project_id**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "createMyPrivateProject",
- "id": 1271580569,
- "params": [
- "my project"
- ]
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1271580569,
- "result": 2
-}
-```
-
-### getMyProjectsList
-
-- Purpose: **Get projects of the connected user**
-- Parameters: None
-- Result on success: **dictionary of project_id => project_name**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getMyProjectsList",
- "id": 987834805
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 987834805,
- "result": {
- "2": "my project"
- }
-}
-```
-### getMyOverdueTasks
-
-- Purpose: **Get my overdue tasks**
-- Result on success: **List of tasks**
-- Result on failure: **false**
-
-Request example to fetch all tasks on the board:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getMyOverdueTasks",
- "id": 133280317
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 133280317,
- "result": [
- {
- "id": "1",
- "title": "Task #1",
- "date_due": "1409961789",
- "project_id": "1",
- "project_name": "Test",
- "assignee_username":"admin",
- "assignee_name": null
- },
- {
- "id": "2",
- "title": "Test",
- "date_due": "1409962115",
- "project_id": "1",
- "project_name": "Test",
- "assignee_username":"admin",
- "assignee_name": null
- },
- ...
- ]
-}
-```
-
-### getMyProjects
-
-- Purpose: **Get projects of connected user with full details**
-- Parameters:
- - **none**
-- Result on success: **List of projects with details**
-- Result on failure: **false**
-
-Request example:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "getmyProjects",
- "id": 2134420212
-}
-```
-
-Response example:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2134420212,
- "result": [
- {
- "id": "1",
- "name": "API test",
- "is_active": "1",
- "token": "",
- "last_modified": "1436119570",
- "is_public": "0",
- "is_private": "0",
- "is_everybody_allowed": "0",
- "default_swimlane": "Default swimlane",
- "show_default_swimlane": "1",
- "description": null,
- "identifier": "",
- "url": {
- "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
- "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
- "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
- }
- }
- ]
-}
-```
+Usage
+-----
+
+- [Authentication](api-authentication.markdown)
+- [Examples](api-examples.markdown)
+- [Application](api-application-procedures.markdown)
+- [Projects](api-project-procedures.markdown)
+- [Project Permissions](api-project-permission-procedures.markdown)
+- [Boards](api-board-procedures.markdown)
+- [Columns](api-column-procedures.markdown)
+- [Swimlanes](api-swimlane-procedures.markdown)
+- [Categories](api-category-procedures.markdown)
+- [Automatic Actions](api-action-procedures.markdown)
+- [Tasks](api-task-procedures.markdown)
+- [Subtasks](api-subtask-procedures.markdown)
+- [Files](api-file-procedures.markdown)
+- [Links](api-link-procedures.markdown)
+- [Comments](api-comment-procedures.markdown)
+- [Users](api-user-procedures.markdown)
+- [Groups](api-group-procedures.markdown)
+- [Group Members](api-group-member-procedures.markdown)
+- [Me](api-me-procedures.markdown)
diff --git a/doc/api-link-procedures.markdown b/doc/api-link-procedures.markdown
new file mode 100644
index 00000000..6113316f
--- /dev/null
+++ b/doc/api-link-procedures.markdown
@@ -0,0 +1,470 @@
+API Link Procedures
+===================
+
+## getAllLinks
+
+- Purpose: **Get the list of possible relations between tasks**
+- Parameters: none
+- Result on success: **List of links**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllLinks",
+ "id": 113057196
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 113057196,
+ "result": [
+ {
+ "id": "1",
+ "label": "relates to",
+ "opposite_id": "0"
+ },
+ {
+ "id": "2",
+ "label": "blocks",
+ "opposite_id": "3"
+ },
+ {
+ "id": "3",
+ "label": "is blocked by",
+ "opposite_id": "2"
+ },
+ {
+ "id": "4",
+ "label": "duplicates",
+ "opposite_id": "5"
+ },
+ {
+ "id": "5",
+ "label": "is duplicated by",
+ "opposite_id": "4"
+ },
+ {
+ "id": "6",
+ "label": "is a child of",
+ "opposite_id": "7"
+ },
+ {
+ "id": "7",
+ "label": "is a parent of",
+ "opposite_id": "6"
+ },
+ {
+ "id": "8",
+ "label": "targets milestone",
+ "opposite_id": "9"
+ },
+ {
+ "id": "9",
+ "label": "is a milestone of",
+ "opposite_id": "8"
+ },
+ {
+ "id": "10",
+ "label": "fixes",
+ "opposite_id": "11"
+ },
+ {
+ "id": "11",
+ "label": "is fixed by",
+ "opposite_id": "10"
+ }
+ ]
+}
+```
+
+## getOppositeLinkId
+
+- Purpose: **Get the opposite link id of a task link**
+- Parameters:
+ - **link_id** (integer, required)
+- Result on success: **link_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getOppositeLinkId",
+ "id": 407062448,
+ "params": [
+ 2
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 407062448,
+ "result": "3"
+}
+```
+
+## getLinkByLabel
+
+- Purpose: **Get a link by label**
+- Parameters:
+ - **label** (integer, required)
+- Result on success: **link properties**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getLinkByLabel",
+ "id": 1796123316,
+ "params": [
+ "blocks"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1796123316,
+ "result": {
+ "id": "2",
+ "label": "blocks",
+ "opposite_id": "3"
+ }
+}
+```
+
+## getLinkById
+
+- Purpose: **Get a link by id**
+- Parameters:
+ - **link_id** (integer, required)
+- Result on success: **link properties**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getLinkById",
+ "id": 1190238402,
+ "params": [
+ 4
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1190238402,
+ "result": {
+ "id": "4",
+ "label": "duplicates",
+ "opposite_id": "5"
+ }
+}
+```
+
+## createLink
+
+- Purpose: **Create a new task relation**
+- Parameters:
+ - **label** (integer, required)
+ - **opposite_label** (integer, optional)
+- Result on success: **link_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createLink",
+ "id": 1040237496,
+ "params": [
+ "foo",
+ "bar"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1040237496,
+ "result": 13
+}
+```
+
+## updateLink
+
+- Purpose: **Update a link**
+- Parameters:
+ - **link_id** (integer, required)
+ - **opposite_link_id** (integer, required)
+ - **label** (string, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateLink",
+ "id": 2110446926,
+ "params": [
+ "14",
+ "12",
+ "boo"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2110446926,
+ "result": true
+}
+```
+
+## removeLink
+
+- Purpose: **Remove a link**
+- Parameters:
+ - **link_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeLink",
+ "id": 2136522739,
+ "params": [
+ "14"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2136522739,
+ "result": true
+}
+```
+
+## createTaskLink
+
+- Purpose: **Create a link between two tasks**
+- Parameters:
+ - **task_id** (integer, required)
+ - **opposite_task_id** (integer, required)
+ - **link_id** (integer, required)
+- Result on success: **task_link_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createTaskLink",
+ "id": 509742912,
+ "params": [
+ 2,
+ 3,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 509742912,
+ "result": 1
+}
+```
+
+## updateTaskLink
+
+- Purpose: **Update task link**
+- Parameters:
+ - **task_link_id** (integer, required)
+ - **task_id** (integer, required)
+ - **opposite_task_id** (integer, required)
+ - **link_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateTaskLink",
+ "id": 669037109,
+ "params": [
+ 1,
+ 2,
+ 4,
+ 2
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 669037109,
+ "result": true
+}
+```
+
+## getTaskLinkById
+
+- Purpose: **Get a task link**
+- Parameters:
+ - **task_link_id** (integer, required)
+- Result on success: **task link properties**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getTaskLinkById",
+ "id": 809885202,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 809885202,
+ "result": {
+ "id": "1",
+ "link_id": "1",
+ "task_id": "2",
+ "opposite_task_id": "3"
+ }
+}
+```
+
+## getAllTaskLinks
+
+- Purpose: **Get all links related to a task**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **list of task link**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllTaskLinks",
+ "id": 810848359,
+ "params": [
+ 2
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 810848359,
+ "result": [
+ {
+ "id": "1",
+ "task_id": "3",
+ "label": "relates to",
+ "title": "B",
+ "is_active": "1",
+ "project_id": "1",
+ "task_time_spent": "0",
+ "task_time_estimated": "0",
+ "task_assignee_id": "0",
+ "task_assignee_username": null,
+ "task_assignee_name": null,
+ "column_title": "Backlog"
+ }
+ ]
+}
+```
+
+## removeTaskLink
+
+- Purpose: **Remove a link between two tasks**
+- Parameters:
+ - **task_link_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeTaskLink",
+ "id": 473028226,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 473028226,
+ "result": true
+}
+```
diff --git a/doc/api-me-procedures.markdown b/doc/api-me-procedures.markdown
new file mode 100644
index 00000000..e90bee61
--- /dev/null
+++ b/doc/api-me-procedures.markdown
@@ -0,0 +1,385 @@
+User API Specific Procedures
+============================
+
+## getMe
+
+- Purpose: **Get logged user session**
+- Parameters: None
+- Result on success: **user session data**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getMe",
+ "id": 1718627783
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1718627783,
+ "result": {
+ "id": 2,
+ "username": "user",
+ "role": "app-user",
+ "is_ldap_user": false,
+ "name": "",
+ "email": "",
+ "google_id": null,
+ "github_id": null,
+ "notifications_enabled": "0",
+ "timezone": null,
+ "language": null,
+ "disable_login_form": "0",
+ "twofactor_activated": false,
+ "twofactor_secret": null,
+ "token": "",
+ "notifications_filter": "4"
+ }
+}
+```
+
+## getMyDashboard
+
+- Purpose: **Get the dashboard of the logged user without pagination**
+- Parameters: None
+- Result on success: **Dashboard information**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getMyDashboard",
+ "id": 447898718
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1563664593,
+ "result": {
+ "projects": [
+ {
+ "id": "2",
+ "name": "my project",
+ "is_active": "1",
+ "token": "",
+ "last_modified": "1438205337",
+ "is_public": "0",
+ "is_private": "1",
+ "is_everybody_allowed": "0",
+ "default_swimlane": "Default swimlane",
+ "show_default_swimlane": "1",
+ "description": null,
+ "identifier": "",
+ "columns": [
+ {
+ "id": "5",
+ "title": "Backlog",
+ "position": "1",
+ "project_id": "2",
+ "task_limit": "0",
+ "description": "",
+ "nb_tasks": 0
+ },
+ {
+ "id": "6",
+ "title": "Ready",
+ "position": "2",
+ "project_id": "2",
+ "task_limit": "0",
+ "description": "",
+ "nb_tasks": 0
+ },
+ {
+ "id": "7",
+ "title": "Work in progress",
+ "position": "3",
+ "project_id": "2",
+ "task_limit": "0",
+ "description": "",
+ "nb_tasks": 0
+ },
+ {
+ "id": "8",
+ "title": "Done",
+ "position": "4",
+ "project_id": "2",
+ "task_limit": "0",
+ "description": "",
+ "nb_tasks": 0
+ }
+ ],
+ "url": {
+ "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=2",
+ "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=2",
+ "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=2"
+ }
+ }
+ ],
+ "tasks": [
+ {
+ "id": "1",
+ "title": "new title",
+ "date_due": "0",
+ "date_creation": "1438205336",
+ "project_id": "2",
+ "color_id": "yellow",
+ "time_spent": "0",
+ "time_estimated": "0",
+ "project_name": "my project",
+ "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=2"
+ }
+ ],
+ "subtasks": []
+ }
+}
+```
+
+## getMyActivityStream
+
+- Purpose: **Get the last 100 events for the logged user**
+- Parameters: None
+- Result on success: **List of events**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getMyActivityStream",
+ "id": 1132562181
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1132562181,
+ "result": [
+ {
+ "id": "1",
+ "date_creation": "1438205054",
+ "event_name": "task.create",
+ "creator_id": "2",
+ "project_id": "2",
+ "task_id": "1",
+ "author_username": "user",
+ "author_name": "",
+ "email": "",
+ "task": {
+ "id": "1",
+ "reference": "",
+ "title": "my user title",
+ "description": "",
+ "date_creation": "1438205054",
+ "date_completed": null,
+ "date_modification": "1438205054",
+ "date_due": "0",
+ "date_started": null,
+ "time_estimated": "0",
+ "time_spent": "0",
+ "color_id": "yellow",
+ "project_id": "2",
+ "column_id": "5",
+ "owner_id": "0",
+ "creator_id": "2",
+ "position": "1",
+ "is_active": "1",
+ "score": "0",
+ "category_id": "0",
+ "swimlane_id": "0",
+ "date_moved": "1438205054",
+ "recurrence_status": "0",
+ "recurrence_trigger": "0",
+ "recurrence_factor": "0",
+ "recurrence_timeframe": "0",
+ "recurrence_basedate": "0",
+ "recurrence_parent": null,
+ "recurrence_child": null,
+ "category_name": null,
+ "swimlane_name": null,
+ "project_name": "my project",
+ "default_swimlane": "Default swimlane",
+ "column_title": "Backlog",
+ "assignee_username": null,
+ "assignee_name": null,
+ "creator_username": "user",
+ "creator_name": ""
+ },
+ "changes": [],
+ "author": "user",
+ "event_title": "user created the task #1",
+ "event_content": "\n<p class=\"activity-title\">\n user created the task <a href=\"\/?controller=task&amp;action=show&amp;task_id=1&amp;project_id=2\" class=\"\" title=\"\" >#1<\/a><\/p>\n<p class=\"activity-description\">\n <em>my user title<\/em>\n<\/p>"
+ }
+ ]
+}
+```
+
+## createMyPrivateProject
+
+- Purpose: **Create a private project for the logged user**
+- Parameters:
+ - **name** (string, required)
+ - **description** (string, optional)
+- Result on success: **project_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createMyPrivateProject",
+ "id": 1271580569,
+ "params": [
+ "my project"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1271580569,
+ "result": 2
+}
+```
+
+## getMyProjectsList
+
+- Purpose: **Get projects of the connected user**
+- Parameters: None
+- Result on success: **dictionary of project_id => project_name**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getMyProjectsList",
+ "id": 987834805
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 987834805,
+ "result": {
+ "2": "my project"
+ }
+}
+```
+## getMyOverdueTasks
+
+- Purpose: **Get my overdue tasks**
+- Result on success: **List of tasks**
+- Result on failure: **false**
+
+Request example to fetch all tasks on the board:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getMyOverdueTasks",
+ "id": 133280317
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 133280317,
+ "result": [
+ {
+ "id": "1",
+ "title": "Task #1",
+ "date_due": "1409961789",
+ "project_id": "1",
+ "project_name": "Test",
+ "assignee_username":"admin",
+ "assignee_name": null
+ },
+ {
+ "id": "2",
+ "title": "Test",
+ "date_due": "1409962115",
+ "project_id": "1",
+ "project_name": "Test",
+ "assignee_username":"admin",
+ "assignee_name": null
+ },
+ ...
+ ]
+}
+```
+
+## getMyProjects
+
+- Purpose: **Get projects of connected user with full details**
+- Parameters:
+ - **none**
+- Result on success: **List of projects with details**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getmyProjects",
+ "id": 2134420212
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2134420212,
+ "result": [
+ {
+ "id": "1",
+ "name": "API test",
+ "is_active": "1",
+ "token": "",
+ "last_modified": "1436119570",
+ "is_public": "0",
+ "is_private": "0",
+ "is_everybody_allowed": "0",
+ "default_swimlane": "Default swimlane",
+ "show_default_swimlane": "1",
+ "description": null,
+ "identifier": "",
+ "url": {
+ "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
+ "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
+ "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
+ }
+ }
+ ]
+}
+```
diff --git a/doc/api-project-permission-procedures.markdown b/doc/api-project-permission-procedures.markdown
new file mode 100644
index 00000000..2844ae3c
--- /dev/null
+++ b/doc/api-project-permission-procedures.markdown
@@ -0,0 +1,274 @@
+Project Permission API Procedures
+=================================
+
+## getProjectUsers
+
+- Purpose: **Get all members of a project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **Dictionary of user_id => user name**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getProjectUsers",
+ "id": 1601016721,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1601016721,
+ "result": {
+ "1": "admin"
+ }
+}
+```
+
+## getAssignableUsers
+
+- Purpose: **Get users that can be assigned to a task for a project** (all members except viewers)
+- Parameters:
+ - **project_id** (integer, required)
+ - **prepend_unassigned** (boolean, optional, default is false)
+- Result on success: **Dictionary of user_id => user name**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAssignableUsers",
+ "id": 658294870,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 658294870,
+ "result": {
+ "1": "admin"
+ }
+}
+```
+
+## addProjectUser
+
+- Purpose: **Grant access to a project for a user**
+- Parameters:
+ - **project_id** (integer, required)
+ - **user_id** (integer, required)
+ - **role** (string, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "addProjectUser",
+ "id": 1294688355,
+ "params": [
+ "1",
+ "1",
+ "project-viewer"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1294688355,
+ "result": true
+}
+```
+
+## addProjectGroup
+
+- Purpose: **Grant access to a project for a group**
+- Parameters:
+ - **project_id** (integer, required)
+ - **group_id** (integer, required)
+ - **role** (string, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "addProjectGroup",
+ "id": 1694959089,
+ "params": [
+ "1",
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1694959089,
+ "result": true
+}
+```
+
+## removeProjectUser
+
+- Purpose: **Revoke user access to a project**
+- Parameters:
+ - **project_id** (integer, required)
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeProjectUser",
+ "id": 645233805,
+ "params": [
+ 1,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 645233805,
+ "result": true
+}
+```
+
+## removeProjectGroup
+
+- Purpose: **Revoke group access to a project**
+- Parameters:
+ - **project_id** (integer, required)
+ - **group_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeProjectGroup",
+ "id": 557146966,
+ "params": [
+ 1,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 557146966,
+ "result": true
+}
+```
+
+## changeProjectUserRole
+
+- Purpose: **Change role of a user for a project**
+- Parameters:
+ - **project_id** (integer, required)
+ - **user_id** (integer, required)
+ - **role** (string, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "changeProjectUserRole",
+ "id": 193473170,
+ "params": [
+ "1",
+ "1",
+ "project-viewer"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 193473170,
+ "result": true
+}
+```
+
+## changeProjectGroupRole
+
+- Purpose: **Change role of a group for a project**
+- Parameters:
+ - **project_id** (integer, required)
+ - **group_id** (integer, required)
+ - **role** (string, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "changeProjectGroupRole",
+ "id": 2114673298,
+ "params": [
+ "1",
+ "1",
+ "project-viewer"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2114673298,
+ "result": true
+}
+```
diff --git a/doc/api-project-procedures.markdown b/doc/api-project-procedures.markdown
new file mode 100644
index 00000000..6cc1b15b
--- /dev/null
+++ b/doc/api-project-procedures.markdown
@@ -0,0 +1,411 @@
+API Project Procedures
+======================
+
+## createProject
+
+- Purpose: **Create a new project**
+- Parameters:
+ - **name** (string, required)
+ - **description** (string, optional)
+- Result on success: **project_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createProject",
+ "id": 1797076613,
+ "params": {
+ "name": "PHP client"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1797076613,
+ "result": 2
+}
+```
+
+## getProjectById
+
+- Purpose: **Get project information**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **project properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getProjectById",
+ "id": 226760253,
+ "params": {
+ "project_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 226760253,
+ "result": {
+ "id": "1",
+ "name": "API test",
+ "is_active": "1",
+ "token": "",
+ "last_modified": "1436119135",
+ "is_public": "0",
+ "is_private": "0",
+ "is_everybody_allowed": "0",
+ "default_swimlane": "Default swimlane",
+ "show_default_swimlane": "1",
+ "description": "test",
+ "identifier": "",
+ "url": {
+ "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
+ "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
+ "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
+ }
+ }
+}
+```
+
+## getProjectByName
+
+- Purpose: **Get project information**
+- Parameters:
+ - **name** (string, required)
+- Result on success: **project properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getProjectByName",
+ "id": 1620253806,
+ "params": {
+ "name": "Test"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1620253806,
+ "result": {
+ "id": "1",
+ "name": "Test",
+ "is_active": "1",
+ "token": "",
+ "last_modified": "1436119135",
+ "is_public": "0",
+ "is_private": "0",
+ "is_everybody_allowed": "0",
+ "default_swimlane": "Default swimlane",
+ "show_default_swimlane": "1",
+ "description": "test",
+ "identifier": "",
+ "url": {
+ "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
+ "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
+ "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
+ }
+ }
+}
+```
+
+## getAllProjects
+
+- Purpose: **Get all available projects**
+- Parameters:
+ - **none**
+- Result on success: **List of projects**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllProjects",
+ "id": 2134420212
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2134420212,
+ "result": [
+ {
+ "id": "1",
+ "name": "API test",
+ "is_active": "1",
+ "token": "",
+ "last_modified": "1436119570",
+ "is_public": "0",
+ "is_private": "0",
+ "is_everybody_allowed": "0",
+ "default_swimlane": "Default swimlane",
+ "show_default_swimlane": "1",
+ "description": null,
+ "identifier": "",
+ "url": {
+ "board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
+ "calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
+ "list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
+ }
+ }
+ ]
+}
+```
+
+## updateProject
+
+- Purpose: **Update a project**
+- Parameters:
+ - **id** (integer, required)
+ - **name** (string, required)
+ - **description** (string, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateProject",
+ "id": 1853996288,
+ "params": {
+ "id": 1,
+ "name": "PHP client update"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1853996288,
+ "result": true
+}
+```
+
+## removeProject
+
+- Purpose: **Remove a project**
+- Parameters:
+ **project_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeProject",
+ "id": 46285125,
+ "params": {
+ "project_id": "2"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 46285125,
+ "result": true
+}
+```
+
+## enableProject
+
+- Purpose: **Enable a project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "enableProject",
+ "id": 1775494839,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1775494839,
+ "result": true
+}
+```
+
+## disableProject
+
+- Purpose: **Disable a project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "disableProject",
+ "id": 1734202312,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1734202312,
+ "result": true
+}
+```
+
+## enableProjectPublicAccess
+
+- Purpose: **Enable public access for a given project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "enableProjectPublicAccess",
+ "id": 103792571,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 103792571,
+ "result": true
+}
+```
+
+## disableProjectPublicAccess
+
+- Purpose: **Disable public access for a given project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "disableProjectPublicAccess",
+ "id": 942472945,
+ "params": [
+ "1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 942472945,
+ "result": true
+}
+```
+
+## getProjectActivity
+
+- Purpose: **Get activity stream for a project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **List of events**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getProjectActivity",
+ "id": 942472945,
+ "params": [
+ "project_id": 1
+ ]
+}
+```
+
+## getProjectActivities
+
+- Purpose: **Get Activityfeed for Project(s)**
+- Parameters:
+ - **project_ids** (integer array, required)
+- Result on success: **List of events**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getProjectActivities",
+ "id": 942472945,
+ "params": [
+ "project_ids": [1,2]
+ ]
+}
+```
diff --git a/doc/api-subtask-procedures.markdown b/doc/api-subtask-procedures.markdown
new file mode 100644
index 00000000..c1dbae37
--- /dev/null
+++ b/doc/api-subtask-procedures.markdown
@@ -0,0 +1,194 @@
+API Subtask procedures
+======================
+
+## createSubtask
+
+- Purpose: **Create a new subtask**
+- Parameters:
+ - **task_id** (integer, required)
+ - **title** (integer, required)
+ - **user_id** (int, optional)
+ - **time_estimated** (int, optional)
+ - **time_spent** (int, optional)
+ - **status** (int, optional)
+- Result on success: **subtask_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createSubtask",
+ "id": 2041554661,
+ "params": {
+ "task_id": 1,
+ "title": "Subtask #1"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2041554661,
+ "result": 45
+}
+```
+
+## getSubtask
+
+- Purpose: **Get subtask information**
+- Parameters:
+ - **subtask_id** (integer)
+- Result on success: **subtask properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getSubtask",
+ "id": 133184525,
+ "params": {
+ "subtask_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 133184525,
+ "result": {
+ "id": "1",
+ "title": "Subtask #1",
+ "status": "0",
+ "time_estimated": "0",
+ "time_spent": "0",
+ "task_id": "1",
+ "user_id": "0"
+ }
+}
+```
+
+## getAllSubtasks
+
+- Purpose: **Get all available subtasks**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **List of subtasks**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllSubtasks",
+ "id": 2087700490,
+ "params": {
+ "task_id": 1
+ }
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2087700490,
+ "result": [
+ {
+ "id": "1",
+ "title": "Subtask #1",
+ "status": "0",
+ "time_estimated": "0",
+ "time_spent": "0",
+ "task_id": "1",
+ "user_id": "0",
+ "username": null,
+ "name": null,
+ "status_name": "Todo"
+ },
+ ...
+ ]
+}
+```
+
+## updateSubtask
+
+- Purpose: **Update a subtask**
+- Parameters:
+ - **id** (integer, required)
+ - **task_id** (integer, required)
+ - **title** (integer, optional)
+ - **user_id** (integer, optional)
+ - **time_estimated** (integer, optional)
+ - **time_spent** (integer, optional)
+ - **status** (integer, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateSubtask",
+ "id": 191749979,
+ "params": {
+ "id": 1,
+ "task_id": 1,
+ "status": 1,
+ "time_spent": 5,
+ "user_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 191749979,
+ "result": true
+}
+```
+
+## removeSubtask
+
+- Purpose: **Remove a subtask**
+- Parameters:
+ - **subtask_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeSubtask",
+ "id": 1382487306,
+ "params": {
+ "subtask_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1382487306,
+ "result": true
+}
+```
diff --git a/doc/api-swimlane-procedures.markdown b/doc/api-swimlane-procedures.markdown
new file mode 100644
index 00000000..c58e56c9
--- /dev/null
+++ b/doc/api-swimlane-procedures.markdown
@@ -0,0 +1,438 @@
+API Swimlane Procedures
+=======================
+
+## getDefaultSwimlane
+
+- Purpose: **Get the default swimlane for a project**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getDefaultSwimlane",
+ "id": 898774713,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 898774713,
+ "result": {
+ "id": "1",
+ "default_swimlane": "Default swimlane",
+ "show_default_swimlane": "1"
+ }
+}
+```
+
+## getActiveSwimlanes
+
+- Purpose: **Get the list of enabled swimlanes of a project (include default swimlane if enabled)**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **List of swimlanes**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getActiveSwimlanes",
+ "id": 934789422,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 934789422,
+ "result": [
+ {
+ "id": 0,
+ "name": "Default swimlane"
+ },
+ {
+ "id": "2",
+ "name": "Swimlane A"
+ }
+ ]
+}
+```
+
+## getAllSwimlanes
+
+- Purpose: **Get the list of all swimlanes of a project (enabled or disabled) and sorted by position**
+- Parameters:
+ - **project_id** (integer, required)
+- Result on success: **List of swimlanes**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllSwimlanes",
+ "id": 509791576,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 509791576,
+ "result": [
+ {
+ "id": "1",
+ "name": "Another swimlane",
+ "position": "1",
+ "is_active": "1",
+ "project_id": "1"
+ },
+ {
+ "id": "2",
+ "name": "Swimlane A",
+ "position": "2",
+ "is_active": "1",
+ "project_id": "1"
+ }
+ ]
+}
+```
+
+## getSwimlane
+
+- Purpose: **Get the a swimlane by id**
+- Parameters:
+ - **swimlane_id** (integer, required)
+- Result on success: **swimlane properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getSwimlane",
+ "id": 131071870,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 131071870,
+ "result": {
+ "id": "1",
+ "name": "Swimlane 1",
+ "position": "1",
+ "is_active": "1",
+ "project_id": "1"
+ }
+}
+```
+
+## getSwimlaneById
+
+- Purpose: **Get the a swimlane by id**
+- Parameters:
+ - **swimlane_id** (integer, required)
+- Result on success: **swimlane properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getSwimlaneById",
+ "id": 131071870,
+ "params": [
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 131071870,
+ "result": {
+ "id": "1",
+ "name": "Swimlane 1",
+ "position": "1",
+ "is_active": "1",
+ "project_id": "1"
+ }
+}
+```
+
+## getSwimlaneByName
+
+- Purpose: **Get the a swimlane by name**
+- Parameters:
+ - **project_id** (integer, required)
+ - **name** (string, required)
+- Result on success: **swimlane properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getSwimlaneByName",
+ "id": 824623567,
+ "params": [
+ 1,
+ "Swimlane 1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 824623567,
+ "result": {
+ "id": "1",
+ "name": "Swimlane 1",
+ "position": "1",
+ "is_active": "1",
+ "project_id": "1"
+ }
+}
+```
+
+## changeSwimlanePosition
+
+- Purpose: **Move up the swimlane position** (only for active swimlanes)
+- Parameters:
+ - **project_id** (integer, required)
+ - **swimlane_id** (integer, required)
+ - **position** (integer, required, must be >= 1)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "changeSwimlanePosition",
+ "id": 99275573,
+ "params": [
+ 1,
+ 2,
+ 3
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 99275573,
+ "result": true
+}
+```
+
+## updateSwimlane
+
+- Purpose: **Update swimlane properties**
+- Parameters:
+ - **swimlane_id** (integer, required)
+ - **name** (string, required)
+ - **description** (string, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateSwimlane",
+ "id": 87102426,
+ "params": [
+ "1",
+ "Another swimlane"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 87102426,
+ "result": true
+}
+```
+
+## addSwimlane
+
+- Purpose: **Add a new swimlane**
+- Parameters:
+ - **project_id** (integer, required)
+ - **name** (string, required)
+ - **description** (string, optional)
+- Result on success: **swimlane_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "addSwimlane",
+ "id": 849940086,
+ "params": [
+ 1,
+ "Swimlane 1"
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 849940086,
+ "result": 1
+}
+```
+
+## removeSwimlane
+
+- Purpose: **Remove a swimlane**
+- Parameters:
+ - **project_id** (integer, required)
+ - **swimlane_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeSwimlane",
+ "id": 1433237746,
+ "params": [
+ 2,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1433237746,
+ "result": true
+}
+```
+
+## disableSwimlane
+
+- Purpose: **Enable a swimlane**
+- Parameters:
+ - **project_id** (integer, required)
+ - **swimlane_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "disableSwimlane",
+ "id": 1433237746,
+ "params": [
+ 2,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1433237746,
+ "result": true
+}
+```
+
+## enableSwimlane
+
+- Purpose: **Enable a swimlane**
+- Parameters:
+ - **project_id** (integer, required)
+ - **swimlane_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "enableSwimlane",
+ "id": 1433237746,
+ "params": [
+ 2,
+ 1
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1433237746,
+ "result": true
+}
+```
diff --git a/doc/api-task-procedures.markdown b/doc/api-task-procedures.markdown
new file mode 100644
index 00000000..486c0a09
--- /dev/null
+++ b/doc/api-task-procedures.markdown
@@ -0,0 +1,636 @@
+API Task Procedures
+===================
+
+## createTask
+
+- Purpose: **Create a new task**
+- Parameters:
+ - **title** (string, required)
+ - **project_id** (integer, required)
+ - **color_id** (string, optional)
+ - **column_id** (integer, optional)
+ - **owner_id** (integer, optional)
+ - **creator_id** (integer, optional)
+ - **date_due**: ISO8601 format (string, optional)
+ - **description** Markdown content (string, optional)
+ - **category_id** (integer, optional)
+ - **score** (integer, optional)
+ - **swimlane_id** (integer, optional)
+ - **recurrence_status** (integer, optional)
+ - **recurrence_trigger** (integer, optional)
+ - **recurrence_factor** (integer, optional)
+ - **recurrence_timeframe** (integer, optional)
+ - **recurrence_basedate** (integer, optional)
+- Result on success: **task_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createTask",
+ "id": 1176509098,
+ "params": {
+ "owner_id": 1,
+ "creator_id": 0,
+ "date_due": "",
+ "description": "",
+ "category_id": 0,
+ "score": 0,
+ "title": "Test",
+ "project_id": 1,
+ "color_id": "green",
+ "column_id": 2,
+ "recurrence_status": 0,
+ "recurrence_trigger": 0,
+ "recurrence_factor": 0,
+ "recurrence_timeframe": 0,
+ "recurrence_basedate": 0
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1176509098,
+ "result": 3
+}
+```
+
+## getTask
+
+- Purpose: **Get task by the unique id**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **task properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getTask",
+ "id": 700738119,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 700738119,
+ "result": {
+ "id": "1",
+ "title": "Task #1",
+ "description": "",
+ "date_creation": "1409963206",
+ "color_id": "blue",
+ "project_id": "1",
+ "column_id": "2",
+ "owner_id": "1",
+ "position": "1",
+ "is_active": "1",
+ "date_completed": null,
+ "score": "0",
+ "date_due": "0",
+ "category_id": "0",
+ "creator_id": "0",
+ "date_modification": "1409963206",
+ "reference": "",
+ "date_started": null,
+ "time_spent": "0",
+ "time_estimated": "0",
+ "swimlane_id": "0",
+ "date_moved": "1430875287",
+ "recurrence_status": "0",
+ "recurrence_trigger": "0",
+ "recurrence_factor": "0",
+ "recurrence_timeframe": "0",
+ "recurrence_basedate": "0",
+ "recurrence_parent": null,
+ "recurrence_child": null,
+ "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=1",
+ "color": {
+ "name": "Yellow",
+ "background": "rgb(245, 247, 196)",
+ "border": "rgb(223, 227, 45)"
+ }
+ }
+}
+```
+
+## 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,
+ "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=5&project_id=1"
+ }
+}
+```
+
+## getAllTasks
+
+- Purpose: **Get all available tasks**
+- Parameters:
+ - **project_id** (integer, required)
+ - **status_id**: The value 1 for active tasks and 0 for inactive (integer, required)
+- Result on success: **List of tasks**
+- Result on failure: **false**
+
+Request example to fetch all tasks on the board:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllTasks",
+ "id": 133280317,
+ "params": {
+ "project_id": 1,
+ "status_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 133280317,
+ "result": [
+ {
+ "id": "1",
+ "title": "Task #1",
+ "description": "",
+ "date_creation": "1409961789",
+ "color_id": "blue",
+ "project_id": "1",
+ "column_id": "2",
+ "owner_id": "1",
+ "position": "1",
+ "is_active": "1",
+ "date_completed": null,
+ "score": "0",
+ "date_due": "0",
+ "category_id": "0",
+ "creator_id": "0",
+ "date_modification": "1409961789",
+ "reference": "",
+ "date_started": null,
+ "time_spent": "0",
+ "time_estimated": "0",
+ "swimlane_id": "0",
+ "date_moved": "1430783191",
+ "recurrence_status": "0",
+ "recurrence_trigger": "0",
+ "recurrence_factor": "0",
+ "recurrence_timeframe": "0",
+ "recurrence_basedate": "0",
+ "recurrence_parent": null,
+ "recurrence_child": null,
+ "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=1"
+ },
+ {
+ "id": "2",
+ "title": "Test",
+ "description": "",
+ "date_creation": "1409962115",
+ "color_id": "green",
+ "project_id": "1",
+ "column_id": "2",
+ "owner_id": "1",
+ "position": "2",
+ "is_active": "1",
+ "date_completed": null,
+ "score": "0",
+ "date_due": "0",
+ "category_id": "0",
+ "creator_id": "0",
+ "date_modification": "1409962115",
+ "reference": "",
+ "date_started": null,
+ "time_spent": "0",
+ "time_estimated": "0",
+ "swimlane_id": "0",
+ "date_moved": "1430783191",
+ "recurrence_status": "0",
+ "recurrence_trigger": "0",
+ "recurrence_factor": "0",
+ "recurrence_timeframe": "0",
+ "recurrence_basedate": "0",
+ "recurrence_parent": null,
+ "recurrence_child": null,
+ "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=2&project_id=1"
+ },
+ ...
+ ]
+}
+```
+
+## getOverdueTasks
+
+- Purpose: **Get all overdue tasks**
+- Result on success: **List of tasks**
+- Result on failure: **false**
+
+Request example to fetch all tasks on the board:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getOverdueTasks",
+ "id": 133280317
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 133280317,
+ "result": [
+ {
+ "id": "1",
+ "title": "Task #1",
+ "date_due": "1409961789",
+ "project_id": "1",
+ "project_name": "Test",
+ "assignee_username":"admin",
+ "assignee_name": null
+ },
+ {
+ "id": "2",
+ "title": "Test",
+ "date_due": "1409962115",
+ "project_id": "1",
+ "project_name": "Test",
+ "assignee_username":"admin",
+ "assignee_name": null
+ },
+ ...
+ ]
+}
+```
+
+## getOverdueTasksByProject
+
+- Purpose: **Get all overdue tasks for a special project**
+- Result on success: **List of tasks**
+- Result on failure: **false**
+
+Request example to fetch all tasks on the board:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getOverdueTasksByProject",
+ "id": 133280317,
+ "params": {
+ "project_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 133280317,
+ "result": [
+ {
+ "id": "1",
+ "title": "Task #1",
+ "date_due": "1409961789",
+ "project_id": "1",
+ "project_name": "Test",
+ "assignee_username":"admin",
+ "assignee_name": null
+ },
+ {
+ "id": "2",
+ "title": "Test",
+ "date_due": "1409962115",
+ "project_id": "1",
+ "project_name": "Test",
+ "assignee_username":"admin",
+ "assignee_name": null
+ },
+ ...
+ ]
+}
+```
+
+## updateTask
+
+- Purpose: **Update a task**
+- Parameters:
+ - **id** (integer, required)
+ - **title** (string, optional)
+ - **color_id** (string, optional)
+ - **owner_id** (integer, optional)
+ - **date_due**: ISO8601 format (string, optional)
+ - **description** Markdown content (string, optional)
+ - **category_id** (integer, optional)
+ - **score** (integer, optional)
+ - **recurrence_status** (integer, optional)
+ - **recurrence_trigger** (integer, optional)
+ - **recurrence_factor** (integer, optional)
+ - **recurrence_timeframe** (integer, optional)
+ - **recurrence_basedate** (integer, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example to change the task color:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateTask",
+ "id": 1406803059,
+ "params": {
+ "id": 1,
+ "color_id": "blue"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1406803059,
+ "result": true
+}
+```
+
+## openTask
+
+- Purpose: **Set a task to the status open**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "openTask",
+ "id": 1888531925,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1888531925,
+ "result": true
+}
+```
+
+## closeTask
+
+- Purpose: **Set a task to the status close**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "closeTask",
+ "id": 1654396960,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1654396960,
+ "result": true
+}
+```
+
+## removeTask
+
+- Purpose: **Remove a task**
+- Parameters:
+ - **task_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeTask",
+ "id": 1423501287,
+ "params": {
+ "task_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1423501287,
+ "result": true
+}
+```
+
+## moveTaskPosition
+
+- Purpose: **Move a task to another column, position or swimlane inside the same board**
+- Parameters:
+ - **project_id** (integer, required)
+ - **task_id** (integer, required)
+ - **column_id** (integer, required)
+ - **position** (integer, required)
+ - **swimlane_id** (integer, optional, default=0)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "moveTaskPosition",
+ "id": 117211800,
+ "params": {
+ "project_id": 1,
+ "task_id": 1,
+ "column_id": 2,
+ "position": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 117211800,
+ "result": true
+}
+```
+
+## moveTaskToProject
+
+- Purpose: **Move a task to another project**
+- Parameters:
+ - **task_id** (integer, required)
+ - **project_id** (integer, required)
+ - **swimlane_id** (integer, optional)
+ - **column_id** (integer, optional)
+ - **category_id** (integer, optional)
+ - **owner_id** (integer, optional)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "moveTaskToProject",
+ "id": 15775829,
+ "params": [
+ 4,
+ 5
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 15775829,
+ "result": true
+}
+```
+
+## duplicateTaskToProject
+
+- Purpose: **Move a task to another column or another position**
+- Parameters:
+ - **task_id** (integer, required)
+ - **project_id** (integer, required)
+ - **swimlane_id** (integer, optional)
+ - **column_id** (integer, optional)
+ - **category_id** (integer, optional)
+ - **owner_id** (integer, optional)
+- Result on success: **task_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "duplicateTaskToProject",
+ "id": 1662458687,
+ "params": [
+ 5,
+ 7
+ ]
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1662458687,
+ "result": 6
+}
+```
diff --git a/doc/api-user-procedures.markdown b/doc/api-user-procedures.markdown
new file mode 100644
index 00000000..6c09355d
--- /dev/null
+++ b/doc/api-user-procedures.markdown
@@ -0,0 +1,357 @@
+API User Procedures
+===================
+
+## createUser
+
+- Purpose: **Create a new user**
+- Parameters:
+ - **username** Must be unique (string, required)
+ - **password** Must have at least 6 characters (string, required)
+ - **name** (string, optional)
+ - **email** (string, optional)
+ - **role** (string, optional, example: app-admin, app-manager, app-user)
+- Result on success: **user_id**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createUser",
+ "id": 1518863034,
+ "params": {
+ "username": "biloute",
+ "password": "123456"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1518863034,
+ "result": 22
+}
+```
+
+## createLdapUser
+
+- Purpose: **Create a new user authentified by LDAP**
+- Parameters:
+ - **username** (string, required)
+- Result on success: **user_id**
+- Result on failure: **false**
+
+The user will only be created if he is found on the LDAP server.
+This method works only with LDAP authentication configured in proxy or anonymous mode.
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "createLdapUser",
+ "id": 1518863034,
+ "params": {
+ "username": "my_ldap_user",
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1518863034,
+ "result": 22
+}
+```
+
+## getUser
+
+- Purpose: **Get user information**
+- Parameters:
+ - **user_id** (integer, required)
+- Result on success: **user properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getUser",
+ "id": 1769674781,
+ "params": {
+ "user_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1769674781,
+ "result": {
+ "id": "1",
+ "username": "biloute",
+ "password": "$2y$10$dRs6pPoBu935RpmsrhmbjevJH5MgZ7Kr9QrnVINwwyZ3.MOwqg.0m",
+ "role": "app-user",
+ "is_ldap_user": "0",
+ "name": "",
+ "email": "",
+ "google_id": null,
+ "github_id": null,
+ "notifications_enabled": "0"
+ }
+}
+```
+
+## getUserByName
+
+- Purpose: **Get user information**
+- Parameters:
+ - **username** (string, required)
+- Result on success: **user properties**
+- Result on failure: **null**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getUserByName",
+ "id": 1769674782,
+ "params": {
+ "username": "biloute"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1769674782,
+ "result": {
+ "id": "1",
+ "username": "biloute",
+ "password": "$2y$10$dRs6pPoBu935RpmsrhmbjevJH5MgZ7Kr9QrnVINwwyZ3.MOwqg.0m",
+ "role": "app-user",
+ "is_ldap_user": "0",
+ "name": "",
+ "email": "",
+ "google_id": null,
+ "github_id": null,
+ "notifications_enabled": "0"
+ }
+}
+```
+
+## getAllUsers
+
+- Purpose: **Get all available users**
+- Parameters:
+ - **none**
+- Result on success: **List of users**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "getAllUsers",
+ "id": 1438712131
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1438712131,
+ "result": [
+ {
+ "id": "1",
+ "username": "biloute",
+ "name": "",
+ "email": "",
+ "role": "app-user",
+ "is_ldap_user": "0",
+ "notifications_enabled": "0",
+ "google_id": null,
+ "github_id": null
+ },
+ ...
+ ]
+}
+```
+
+## updateUser
+
+- Purpose: **Update a user**
+- Parameters:
+ - **id** (integer)
+ - **username** (string, optional)
+ - **name** (string, optional)
+ - **email** (string, optional)
+ - **role** (string, optional, example: app-admin, app-manager, app-user)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "updateUser",
+ "id": 322123657,
+ "params": {
+ "id": 1,
+ "role": "app-manager"
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 322123657,
+ "result": true
+}
+```
+
+## removeUser
+
+- Purpose: **Remove a user**
+- Parameters:
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "removeUser",
+ "id": 2094191872,
+ "params": {
+ "user_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2094191872,
+ "result": true
+}
+```
+
+## disableUser
+
+- Purpose: **Disable a user**
+- Parameters:
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "disableUser",
+ "id": 2094191872,
+ "params": {
+ "user_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2094191872,
+ "result": true
+}
+```
+
+## enableUser
+
+- Purpose: **Enable a user**
+- Parameters:
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "enableUser",
+ "id": 2094191872,
+ "params": {
+ "user_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2094191872,
+ "result": true
+}
+```
+
+## isActiveUser
+
+- Purpose: **Check if a user is active**
+- Parameters:
+ - **user_id** (integer, required)
+- Result on success: **true**
+- Result on failure: **false**
+
+Request example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "isActiveUser",
+ "id": 2094191872,
+ "params": {
+ "user_id": 1
+ }
+}
+```
+
+Response example:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2094191872,
+ "result": true
+}
+```
diff --git a/doc/application-configuration.markdown b/doc/application-configuration.markdown
index 87929732..e3ddf6e9 100644
--- a/doc/application-configuration.markdown
+++ b/doc/application-configuration.markdown
@@ -11,23 +11,23 @@ Go to the menu **Settings**, then choose **Application settings** on the left.
### Application URL
This parameter is used for email notifications.
-The email footer will contains a link to the Kanboard task.
+The email footer will contain a link to the Kanboard task.
### Language
The application language can be changed at anytime.
The language will be set for all users.
-### Timezone
+### Time zone
-By default, Kanboard use UTC as timezone, but you can define your own timezone.
-The list contains all supported timezones by your web server.
+By default, Kanboard use UTC as time zone, but you can define your own time zone.
+The list contains all supported time zones by your web server.
### Date format
-Input format used for date fields, by example the due date for tasks.
+Input format used for date fields, for examples the due date for tasks.
-Kanboard offer 4 different formats:
+Kanboard offers 4 different formats:
- DD/MM/YYYY
- MM/DD/YYYY (default)
diff --git a/doc/automatic-actions.markdown b/doc/automatic-actions.markdown
index 71dfa90a..db56ccc0 100644
--- a/doc/automatic-actions.markdown
+++ b/doc/automatic-actions.markdown
@@ -6,17 +6,17 @@ To minimize the user interaction, Kanboard support automated actions.
Each automatic action is defined like that:
- An event to listen
-- An action linked to this event
+- Action linked to this event
- Eventually there is some parameters to define
-Each project have a different set of automatic actions, the configuration panel is located on the project listing page, just click on the link **Automatic actions**.
+Each project has a different set of automatic actions, the configuration panel is located on the project listing page, just click on the link **Automatic actions**.
Add a new action
----------------
### Choose an action
-![Choose an action](http://kanboard.net/screenshots/documentation/project-automatic-action-step1.png)
+![Choose action](http://kanboard.net/screenshots/documentation/project-automatic-action-step1.png)
### Choose an event
@@ -26,70 +26,44 @@ Add a new action
![Define parameters](http://kanboard.net/screenshots/documentation/project-automatic-action-step3.png)
-List of available events
-------------------------
-
-- Move a task to another column
-- Move a task to another position in the same column
-- Task modification
-- Task creation
-- Reopen a task
-- Closing a task
-- Task creation or modification
-- Task assignee change
-- Task link created or updated
-- Github commit received
-- Github issue opened
-- Github issue closed
-- Github issue reopened
-- Github issue assignee change
-- Github issue label change
-- Github issue comment created
-- Gitlab issue opened
-- Gitlab issue closed
-- Gitlab commit received
-- Bitbucket commit received
-- Bitbucket issue opened
-- Bitbucket issue closed
-- Bitbucket issue reopened
-- Bitbucket issue assignee change
-- Bitbucket issue comment created
-
List of available actions
-------------------------
-- Close the task
-- Open a task
-- Assign the task to a specific user
+- Create a comment from an external provider
+- Add a comment log when moving the task between columns
+- Assign automatically a category based on a color
+- Change the category based on an external label
+- Assign automatically a category based on a link
+- Assign automatically a color based on a category
+- Assign a color when the task is moved to a specific column
+- Change task color when using a specific task link
+- Assign a color to a specific user
- Assign the task to the person who does the action
+- Assign the task to the person who does the action when the column is changed
+- Assign the task to a specific user
+- Change the assignee based on an external username
+- Close the task
+- Close a task in a specific column
+- Create a task from an external provider
- Duplicate the task to another project
+- Send a task by email to someone
- Move the task to another project
- Move the task to another column when assigned to a user
+- Move the task to another column when the category is changed
- Move the task to another column when assignee is cleared
-- Assign a color when the task is moved to a specific column
-- Assign a color to a specific user
-- Assign automatically a color based on a category
-- Assign automatically a category based on a color
-- Create a comment from an external provider
-- Create a task from an external provider
-- Add a comment log when moving the task between columns
-- Change the assignee based on an external username
-- Change the category based on an external label
+- Open a task
- Automatically update the start date
-- Move the task to another column when the category is changed
-- Send a task by email to someone
-- Change task color when using a specific task link
Examples
--------
-Here are some examples used in the real life:
+Here are some examples used in real life:
### When I move a task to the column "Done", automatically close this task
-- Choose the action: **Close the task**
+- Choose action: **Close a task in a specific column**
- Choose the event: **Move a task to another column**
-- Define the action parameter: **Column = Done** (this is the destination column)
+- Define action parameter: **Column = Done** (this is the destination column)
### When I move a task to the column "To be validated", assign this task to a specific user
@@ -99,40 +73,40 @@ Here are some examples used in the real life:
### When I move a task to the column "Work in progress", assign this task to the current user
-- Choose the action: **Assign the task to the person who does the action**
+- Choose action: **Assign the task to the person who does the action when the column is changed**
- Choose the event: **Move a task to another column**
-- Define the action parameter: **Column = Work in progress**
+- Define action parameter: **Column = Work in progress**
### When a task is completed, duplicate this task to another project
Let's say we have two projects "Customer orders" and "Production", once the order is validated, swap it to the "Production" project.
-- Choose the action: **Duplicate the task to another project**
+- Choose action: **Duplicate the task to another project**
- Choose the event: **Closing a task**
-- Define the action parameters: **Column = Validated** and **Project = Production**
+- Define action parameters: **Column = Validated** and **Project = Production**
### When a task is moved to the last column, move the exact same task to another project
Let's say we have two projects "Ideas" and "Development", once the idea is validated, swap it to the "Development" project.
-- Choose the action: **Move the task to another project**
+- Choose action: **Move the task to another project**
- Choose the event: **Move a task to another column**
-- Define the action parameters: **Column = Validated** and **Project = Development**
+- Define action parameters: **Column = Validated** and **Project = Development**
### I want to assign automatically a color to the user Bob
-- Choose the action: **Assign a color to a specific user**
+- Choose action: **Assign a color to a specific user**
- Choose the event: **Task assignee change**
-- Define the action parameters: **Color = Green** and **Assignee = Bob**
+- Define action parameters: **Color = Green** and **Assignee = Bob**
-### I want to assign automatically a color to the defined category "Feature Request"
+### I want to assign a color automatically to the defined category "Feature Request"
-- Choose the action: **Assign automatically a color based on a category**
+- Choose action: **Assign automatically a color based on a category**
- Choose the event: **Task creation or modification**
-- Define the action parameters: **Color = Blue** and **Category = Feature Request**
+- Define action parameters: **Color = Blue** and **Category = Feature Request**
### I want to set the start date automatically when the task is moved to the column "Work in progress"
-- Choose the action: **Automatically update the start date**
+- Choose action: **Automatically update the start date**
- Choose the event: **Move a task to another column**
-- Define the action parameters: **Column = Work in progress**
+- Define action parameters: **Column = Work in progress**
diff --git a/doc/bitbucket-webhooks.markdown b/doc/bitbucket-webhooks.markdown
deleted file mode 100644
index 7f59aa11..00000000
--- a/doc/bitbucket-webhooks.markdown
+++ /dev/null
@@ -1,55 +0,0 @@
-Bitbucket webhooks
-==================
-
-Bitbucket events can be connected to Kanboard automatic actions.
-
-List of supported events
-------------------------
-
-- Bitbucket commit received
-- Bitbucket issue opened
-- Bitbucket issue closed
-- Bitbucket issue reopened
-- Bitbucket issue assignee change
-- Bitbucket issue comment created
-
-List of supported actions
--------------------------
-
-- Create a task from an external provider
-- Change the assignee based on an external username
-- Create a comment from an external provider
-- Close a task
-- Open a task
-
-Configuration
--------------
-
-![Bitbucket configuration](http://kanboard.net/screenshots/documentation/bitbucket-webhooks.png)
-
-1. On Kanboard, go to the project settings and choose the section **Integrations**
-2. Copy the Bitbucket webhook url
-3. On Bitbucket, go to the project settings and go to the section **Webhooks**
-4. Choose a title for your webhook and paste the Kanboard url
-
-Examples
---------
-
-### Close a Kanboard task when a commit pushed to Bitbucket
-
-- Choose the event: **Bitbucket commit received**
-- Choose the action: **Close the task**
-
-When one or more commits are sent to Bitbucket, Kanboard will receive the information, each commit message with a task number included will be closed.
-
-Example:
-
-- Commit message: "Fix bug #1234"
-- That will close the Kanboard task #1234
-
-### Add comment when a commit received
-
-- Choose the event: **Bitbucket commit received**
-- Choose the action: **Create a comment from an external provider**
-
-The comment will contains the commit message and the url to the commit.
diff --git a/doc/board-collapsed-expanded.markdown b/doc/board-collapsed-expanded.markdown
index e094e817..594676bc 100644
--- a/doc/board-collapsed-expanded.markdown
+++ b/doc/board-collapsed-expanded.markdown
@@ -2,7 +2,7 @@ Collapsed and Expanded mode
===========================
Tasks on the board can be displayed in collapsed or in expanded mode.
-Switching from one view to another can be done with the keyboard shortcut **"s"** or by using the dropdown menu on the left.
+Switching from one view to another can be done with the keyboard shortcut **"s"** or by using the drop-down menu on the left.
Collapsed mode
--------------
diff --git a/doc/board-configuration.markdown b/doc/board-configuration.markdown
index 1c5ff51a..bdcc2463 100644
--- a/doc/board-configuration.markdown
+++ b/doc/board-configuration.markdown
@@ -7,7 +7,7 @@ Go to the menu **Settings**, then choose **Board settings** on the left.
### Task highlighting
-This feature display a shadow around the task when a task is moved recently.
+This feature displays a shadow around the task when a task is moved recently.
Set the value 0 to disable this feature, 2 days by default (172800 seconds).
@@ -15,10 +15,10 @@ Everything moved since 2 days will have shadow around the task.
### Refresh interval for public board
-When you share a board, the page will refresh automatically every 60 seconds by default.
+When you share a board, the page will refresh every 60 seconds automatically by default.
### Refresh interval for private board
-When your web browser is open on a board, Kanboard check every 10 seconds if something have been changed by someone else.
+When your web browser is open on a board, Kanboard checks every 10 seconds if something has been changed by someone else.
Technically this process is done by Ajax polling.
diff --git a/doc/board-horizontal-scrolling-and-compact-view.markdown b/doc/board-horizontal-scrolling-and-compact-view.markdown
index 5dc5dcc0..323d0de5 100644
--- a/doc/board-horizontal-scrolling-and-compact-view.markdown
+++ b/doc/board-horizontal-scrolling-and-compact-view.markdown
@@ -1,12 +1,12 @@
Horizontal scrolling and compact mode
=====================================
-When the board cannot fit on your screen, an horizontal scroll bar will appear at the bottom.
+When the board cannot fit on your screen, a horizontal scroll bar will appear at the bottom.
However, it's possible to switch to the compact the view to display all columns in your screen.
![Board in compact mode](http://kanboard.net/screenshots/documentation/board-compact-mode.png)
-Switching between horizontal scrolling and compact view can be done with the keyboard shortcut **"c"** or by using the dropdown menu on the top left.
+Switching between horizontal scrolling and compact view can be done with the keyboard shortcut **"c"** or by using the drop-down menu on the top left.
Note: It's possible that text overlaps in compact mode, that will be improved over the next releases. \ No newline at end of file
diff --git a/doc/board-show-hide-columns.markdown b/doc/board-show-hide-columns.markdown
index e459f555..40edc5d8 100644
--- a/doc/board-show-hide-columns.markdown
+++ b/doc/board-show-hide-columns.markdown
@@ -8,4 +8,4 @@ You can hide or display columns very easily on the board:
- To hide a column, just click on the column title
- To show a hidden column, click on the vertical title
-When a column is hidden the number of tasks is displayed at the top.
+When a column is hidden, the number of tasks is displayed at the top.
diff --git a/doc/bruteforce-protection.markdown b/doc/bruteforce-protection.markdown
index 633cfe87..a7bef45e 100644
--- a/doc/bruteforce-protection.markdown
+++ b/doc/bruteforce-protection.markdown
@@ -1,14 +1,14 @@
-Bruteforce Protection
-=====================
+Brute Force Protection
+======================
The brute force protection of Kanboard works at the user account level:
-- After 3 authentication failure for the same username, the login form show a captcha image to prevent automated bot tentatives.
+- After 3 authentication failure for the same username, the login form shows a captcha image to prevent automated bot tentatives.
- After 6 authentication failure, the user account is locked down for a period of 15 minutes.
This feature works only for authentication methods that use the login form.
-However, **after 3 authentication failure through the user API**, the account have to be unlocked by using the login form.
+However, **after three authentication failure through the user API**, the account has to be unlocked by using the login form.
Kanboard doesn't block any IP addresses since bots can use several anonymous proxies. However, you can use external tools like [fail2ban](http://www.fail2ban.org) to avoid massive scans.
@@ -21,6 +21,6 @@ define('BRUTEFORCE_CAPTCHA', 3);
// Lock the account after 6 authentication failure
define('BRUTEFORCE_LOCKDOWN', 6);
-// Lock account duration in minute
+// Lock account duration in minutes
define('BRUTEFORCE_LOCKDOWN_DURATION', 15);
```
diff --git a/doc/calendar-configuration.markdown b/doc/calendar-configuration.markdown
index 95e13e52..438ba37a 100644
--- a/doc/calendar-configuration.markdown
+++ b/doc/calendar-configuration.markdown
@@ -13,7 +13,7 @@ There are two different calendars in Kanboard:
Project calendar
----------------
-This calendar show tasks with defined due date and tasks based on the creation date or the start date.
+This calendar shows tasks with defined due date and tasks based on the creation date or the start date.
### Show tasks based on the creation date
@@ -30,13 +30,13 @@ This calendar show tasks with defined due date and tasks based on the creation d
User calendar
-------------
-This calendar show only tasks assigned to the user and optionally subtasks information.
+This calendar shows only tasks assigned to the user and optionally sub-tasks information.
-### Show subtasks based on the time tracking
+### Show sub-tasks based on the time tracking
-- Display subtasks in the calendar from the information recorded in the time tracking table.
+- Display sub-tasks in the calendar from the information recorded in the time tracking table.
- The intersection with the user timetable is also calculated.
-### Show subtask estimates (forecast of future work)
+### Show sub-task estimates (forecast of future work)
-- Display the estimate of future work for subtasks in status "todo" and with a defined "estimate" value.
+- Display the estimate of future work for sub-tasks in status "todo" and with a defined "estimate" value.
diff --git a/doc/calendar.markdown b/doc/calendar.markdown
index 7b95baa2..6a790b1e 100644
--- a/doc/calendar.markdown
+++ b/doc/calendar.markdown
@@ -6,15 +6,15 @@ There are two different views for the calendar:
- The project view with filters (available from the board)
- The user view (available from the dashboard and from the user section)
-At this time the calendar is able to display these information:
+At this time the calendar is able to display this information:
- Tasks with a due date, displayed at the top. **The due date can be changed by moving the task to another day**.
- Tasks based on the creation date or the start date. **These events cannot be modified with the calendar**.
-- Subtask time tracking, all recorded time slot will be shown in the calendar.
-- Subtask estimates, forecast of work left
+- Sub-task time tracking, all recorded time slot will be shown in the calendar.
+- Sub-task estimates, forecasts of work left
![Calendar](http://kanboard.net/screenshots/documentation/calendar.png)
The calendar configuration can be changed in the settings page.
-Note: The due date doesn't contains time information.
+Note: The due date doesn't contain time information.
diff --git a/doc/centos-installation.markdown b/doc/centos-installation.markdown
index 6cfc31ff..576119b4 100644
--- a/doc/centos-installation.markdown
+++ b/doc/centos-installation.markdown
@@ -1,6 +1,8 @@
Centos Installation
===================
+Note: Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
+
Centos 7
--------
@@ -40,7 +42,7 @@ Be sure to configure your server to allow Kanboard to send emails and make exter
setsebool -P httpd_can_network_connect=1
```
-Allowing external connections is necessary if you use LDAP, SMTP, Webhooks or any third-party integrations.
+Allowing external connections is necessary if you use LDAP, SMTP, Web hooks or any third-party integration.
Centos 6.x
----------
diff --git a/doc/cli.markdown b/doc/cli.markdown
index 38cba496..9334d84b 100644
--- a/doc/cli.markdown
+++ b/doc/cli.markdown
@@ -1,10 +1,10 @@
Command Line Interface
======================
-Kanboard provide a simple command line interface that can be used from any Unix terminal.
+Kanboard provides a simple command line interface that can be used from any Unix terminal.
This tool can be used only on the local machine.
-This feature is useful to run commands outside the web server process by example running a huge report.
+This feature is useful to run commands outside of the web server processes.
Usage
-----
@@ -28,6 +28,7 @@ Options:
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands:
+ cronjob Execute daily cronjob
help Displays help for a command
list Lists commands
export
@@ -42,6 +43,8 @@ Available commands:
notification:overdue-tasks Send notifications for overdue tasks
projects
projects:daily-stats Calculate daily statistics for all projects
+ trigger
+ trigger:tasks Trigger scheduler event for all tasks
```
Available commands
@@ -116,7 +119,7 @@ Emails will be sent to all users with notifications enabled.
You can also display the overdue tasks with the flag `--show`:
```bash
-$ ./kanboard notification:overdue-tasks --show
+./kanboard notification:overdue-tasks --show
+-----+---------+------------+------------+--------------+----------+
| Id | Title | Due date | Project Id | Project name | Assignee |
+-----+---------+------------+------------+--------------+----------+
@@ -125,20 +128,22 @@ $ ./kanboard notification:overdue-tasks --show
+-----+---------+------------+------------+--------------+----------+
```
-Cronjob example:
-
-```bash
-# Everyday at 8am we check for due tasks
-0 8 * * * cd /path/to/kanboard && ./kanboard notification:overdue-tasks >/dev/null 2>&1
-```
-
### Run daily project stats calculation
-You can add a background task to calculate the project statistics everyday:
+This command calculate the statistics of each project:
```bash
-$ ./kanboard projects:daily-stats
+./kanboard projects:daily-stats
Run calculation for Project #0
Run calculation for Project #1
Run calculation for Project #10
```
+
+### Trigger for tasks
+
+This command send a "daily cronjob event" to all open tasks of each project.
+
+```bash
+./kanboard trigger:tasks
+Trigger task event: project_id=2, nb_tasks=1
+```
diff --git a/doc/closing-tasks.markdown b/doc/closing-tasks.markdown
index 018acace..dc3ef134 100644
--- a/doc/closing-tasks.markdown
+++ b/doc/closing-tasks.markdown
@@ -3,14 +3,14 @@ Closing tasks
When a task is closed, it is hidden from the board.
-However, you can always access to the list of closed tasks by using the query **status:closed** in any search form or simply choose **Closed tasks** from the filter dropdown.
+However, you can always access to the list of closed tasks by using the query **status:closed** in any search form or simply choose **Closed tasks** from the filter drop-down.
-There are two different ways to close a task, from the task dropdown menu on the board:
+There are two different ways to close a task, from the task drop-down menu on the board:
-![Close a task from dropdown menu](http://kanboard.net/screenshots/documentation/menu-close-task.png)
+![Close a task from drop-down menu](http://kanboard.net/screenshots/documentation/menu-close-task.png)
Or from the task sidebar menu in the task detail view:
-![Close a task](http://kanboard.net/screenshots/documentation/closing-tasks.png)
+![Close task](http://kanboard.net/screenshots/documentation/closing-tasks.png)
-Note: When you close a task, all subtasks not completed will be changed to the status "Done".
+Note: When you close a task, all sub-tasks not completed will be changed to the status "Done".
diff --git a/doc/coding-standards.markdown b/doc/coding-standards.markdown
index 4d3db814..0ee3ecd6 100644
--- a/doc/coding-standards.markdown
+++ b/doc/coding-standards.markdown
@@ -11,7 +11,7 @@ PHP code
- Always write PHPdoc comments for methods and class properties
- Coding style: [PSR-1](http://www.php-fig.org/psr/psr-1/) and [PSR-2](http://www.php-fig.org/psr/psr-2/)
-Javascript code
+JavaScript code
---------------
- Indentation: 4 spaces
diff --git a/doc/config.markdown b/doc/config.markdown
index f375b2fc..150cb6dc 100644
--- a/doc/config.markdown
+++ b/doc/config.markdown
@@ -1,8 +1,8 @@
Config file
===========
-You can customize the default settings of Kanboard by adding a file `config.php` at the project root.
-You can also rename the `config.default.php` and change the desired values.
+You can customize the default settings of Kanboard by adding a file `config.php` at the project root or in the `data` folder.
+You can also rename the file `config.default.php` to `config.php` and change the desired values.
Enable/Disable debug mode
-------------------------
@@ -102,87 +102,76 @@ define('LDAP_SERVER', '');
// LDAP server port (389 by default)
define('LDAP_PORT', 389);
-// By default, require certificate to be verified for ldaps:// style URL. Set to false to skip the verification.
+// By default, require certificate to be verified for ldaps:// style URL. Set to false to skip the verification
define('LDAP_SSL_VERIFY', true);
// Enable LDAP START_TLS
define('LDAP_START_TLS', false);
-// LDAP bind type: "anonymous", "user" (use the given user/password from the form) and "proxy" (a specific user to browse the LDAP directory)
+// By default Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive)
+// Set to true if you want to preserve the case
+define('LDAP_USERNAME_CASE_SENSITIVE', false);
+
+// LDAP bind type: "anonymous", "user" or "proxy"
define('LDAP_BIND_TYPE', 'anonymous');
-// LDAP username to connect with. null for anonymous bind (by default).
-// Or for user bind type, you can use a pattern: %s@kanboard.local
+// LDAP username to use with proxy mode
+// LDAP username pattern to use with user mode
define('LDAP_USERNAME', null);
-// LDAP password to connect with. null for anonymous bind (by default).
+// LDAP password to use for proxy mode
define('LDAP_PASSWORD', null);
-// LDAP account base, i.e. root of all user account
-// Example: ou=People,dc=example,dc=com
-define('LDAP_ACCOUNT_BASE', '');
+// LDAP DN for users
+// Example for ActiveDirectory: CN=Users,DC=kanboard,DC=local
+// Example for OpenLDAP: ou=People,dc=example,dc=com
+define('LDAP_USER_BASE_DN', '');
-// LDAP query pattern to use when searching for a user account
+// LDAP pattern to use when searching for a user account
// Example for ActiveDirectory: '(&(objectClass=user)(sAMAccountName=%s))'
// Example for OpenLDAP: 'uid=%s'
-define('LDAP_USER_PATTERN', '');
-
-// Name of an attribute of the user account object which should be used as the full name of the user.
-define('LDAP_ACCOUNT_FULLNAME', 'displayname');
+define('LDAP_USER_FILTER', '');
-// Name of an attribute of the user account object which should be used as the email of the user.
-define('LDAP_ACCOUNT_EMAIL', 'mail');
-
-// Name of an attribute of the user account object which should be used as the id of the user.
+// LDAP attribute for username
// Example for ActiveDirectory: 'samaccountname'
// Example for OpenLDAP: 'uid'
-define('LDAP_ACCOUNT_ID', 'samaccountname');
-
-// LDAP Attribute for group membership
-define('LDAP_ACCOUNT_MEMBEROF', 'memberof');
-
-// DN for administrators
-// Example: CN=Kanboard Admins,CN=Users,DC=kanboard,DC=local
-define('LDAP_GROUP_ADMIN_DN', '');
-
-// DN for project administrators
-// Example: CN=Kanboard Project Admins,CN=Users,DC=kanboard,DC=local
-define('LDAP_GROUP_PROJECT_ADMIN_DN', '');
+define('LDAP_USER_ATTRIBUTE_USERNAME', 'uid');
-// By default Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive)
-// Set to true if you want to preserve the case
-define('LDAP_USERNAME_CASE_SENSITIVE', false);
+// LDAP attribute for user full name
+// Example for ActiveDirectory: 'displayname'
+// Example for OpenLDAP: 'cn'
+define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn');
-// Automatically create user account
-define('LDAP_ACCOUNT_CREATION', true);
-```
+// LDAP attribute for user email
+define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail');
-Google Authentication settings
-------------------------------
+// LDAP attribute to find groups in user profile
+define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof');
-```php
-// Enable/disable Google authentication
-define('GOOGLE_AUTH', false);
+// Allow automatic LDAP user creation
+define('LDAP_USER_CREATION', true);
-// Google client id (Get this value from the Google developer console)
-define('GOOGLE_CLIENT_ID', '');
+// LDAP DN for administrators
+// Example: CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local
+define('LDAP_GROUP_ADMIN_DN', '');
-// Google client secret key (Get this value from the Google developer console)
-define('GOOGLE_CLIENT_SECRET', '');
-```
+// LDAP DN for managers
+// Example: CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local
+define('LDAP_GROUP_MANAGER_DN', '');
-Github Authentication settings
-------------------------------
+// Enable LDAP group provider for project permissions
+// The end-user will be able to browse LDAP groups from the user interface and allow access to specified projects
+define('LDAP_GROUP_PROVIDER', false);
-```php
-// Enable/disable GitHub authentication
-define('GITHUB_AUTH', false);
+// LDAP Base DN for groups
+define('LDAP_GROUP_BASE_DN', '');
-// GitHub client id (Copy it from your settings -> Applications -> Developer applications)
-define('GITHUB_CLIENT_ID', '');
+// LDAP group filter
+// Example for ActiveDirectory: (&(objectClass=group)(sAMAccountName=%s*))
+define('LDAP_GROUP_FILTER', '');
-// GitHub client secret key (Copy it from your settings -> Applications -> Developer applications)
-define('GITHUB_CLIENT_SECRET', '');
+// LDAP attribute for the group name
+define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn');
```
Reverse-Proxy Authentication settings
@@ -277,4 +266,10 @@ define('API_AUTHENTICATION_HEADER', '');
// Hide login form, useful if all your users use Google/Github/ReverseProxy authentication
define('HIDE_LOGIN_FORM', false);
+
+// Disabling logout (for external SSO authentication)
+define('DISABLE_LOGOUT', false);
+
+// Override API token stored in the database, useful for automated tests
+define('API_AUTHENTICATION_TOKEN', 'My unique API Token');
```
diff --git a/doc/contributing.markdown b/doc/contributing.markdown
index 63c56782..088d2cce 100644
--- a/doc/contributing.markdown
+++ b/doc/contributing.markdown
@@ -4,7 +4,7 @@ Contributor Guidelines
How can I help?
---------------
-Kanboard is not perfect but there is many ways to help:
+Kanboard is not perfect but there are many ways to help:
- Give feedback
- Report bugs
@@ -18,11 +18,11 @@ Before doing any large undertaking, open a new issue and explain your proposal.
I want to give feedback
-----------------------
-- You think something should be improved (user interface, feature request)
+- You think something should be improved (user interface, feature requests)
- Check if your idea is not already proposed
- Open a new issue
- Describe your idea
-- You can also up vote with +1 on existing proposals
+- You can also up-vote with +1 on existing proposals
I want to report a bug
----------------------
@@ -31,7 +31,7 @@ I want to report a bug
- Open a new ticket
- Explain what is broken
- Describe how to reproduce the bug
-- Describe your environment (Kanboard version, OS, web server, PHP version, database version, hosting type)
+- Describe your environment (Kanboard version, OS, web server, PHP version, database version, hosting provider)
I want to translate Kanboard
----------------------------
@@ -44,18 +44,19 @@ I want to improve the documentation
- You think something is not clear, there is grammatical errors, typo errors, anything.
- The documentation is written in Markdown and stored in the folder `docs`.
-- Edit the file and send a pull-request.
+- Edit the files and send a pull-request.
- The documentation on the official website is synchronized with the repository.
I want to contribute to the code
--------------------------------
-Pull-requests are always welcome, however to be accepted you have to follow those directives:
+Pull-requests are always welcome however, to be accepted you have to follow those directives:
- **Before doing any large change or design proposal, open a new ticket to start a discussion.**
- If you want to add a new feature, respect the philosophy behind Kanboard. **We focus on simplicity**, we don't want to have a bloated software.
- The same apply for the user interface, **simplicity and efficiency**.
-- Send only one pull-request per feature or bug fix, your patch will be merged into one single commit in the master branch.
+- Send only one pull-request per feature or bug fix.
+- A smaller pull-request is easier to review and faster it will be merged.
- Make sure the [unit tests pass](tests.markdown).
- Respect the [coding standards](coding-standards.markdown).
- Write maintainable code, avoid code duplication, use PHP good practices.
diff --git a/doc/create-tasks-by-email.markdown b/doc/create-tasks-by-email.markdown
index 46dae480..b46e5797 100644
--- a/doc/create-tasks-by-email.markdown
+++ b/doc/create-tasks-by-email.markdown
@@ -2,12 +2,13 @@ Create tasks by email
=====================
You can create tasks directly by sending an email.
+This feature is available by using plugins.
At the moment, Kanboard is integrated with 3 external services:
-- [Mailgun](http://kanboard.net/documentation/mailgun)
-- [Sendgrid](http://kanboard.net/documentation/sendgrid)
-- [Postmark](http://kanboard.net/documentation/postmark)
+- [Mailgun](https://github.com/kanboard/plugin-mailgun)
+- [Sendgrid](https://github.com/kanboard/plugin-sendgrid)
+- [Postmark](https://github.com/kanboard/plugin-postmark)
These services handle incoming emails without having to configure any SMTP server.
@@ -17,18 +18,18 @@ All complicated works are already handled by those services.
Incoming emails workflow
------------------------
-1. You send an email to a specific address, by example **something+myproject@inbound.mydomain.tld**
+1. You send an email to a specific address, for example **something+myproject@inbound.mydomain.tld**
2. Your email is forwarded to the third-party SMTP servers
-3. The SMTP provider call the Kanboard webhook with the email in JSON or multipart/form-data formats
-4. Kanboard parse the received email and create the task to the right project
+3. The SMTP provider call the Kanboard web hook with the email in JSON or multipart/form-data formats
+4. Kanboard parses the received email and create the task to the right project
Note: New tasks are automatically created in the first column.
Email format
------------
-- The local part of the email address must use the plus separator, by example **kanboard+project123**
-- The string defined after the plus sign must match a project identifier, by example **project123** is the identifier of the project **Project 123**
+- The local part of the email address must use the plus separator, for example **kanboard+project123**
+- The string defined after the plus sign must match a project identifier, for example **project123** is the identifier of the project **Project 123**
- The email subject becomes the task title
- The email body becomes the task description (Markdown format)
@@ -38,7 +39,7 @@ Incoming emails can be written in text or HTML formats.
Security and requirements
-------------------------
-- The Kanboard webhook is protected by a random token
+- The Kanboard web hook is protected by a random token
- The sender email address must match a Kanboard user
-- The Kanboard project must have a unique identifier, by example **MYPROJECT**
-- The Kanboard user must be member of the project
+- The Kanboard project must have a unique identifier, for example **MYPROJECT**
+- The Kanboard user must be a member of the project
diff --git a/doc/creating-projects.markdown b/doc/creating-projects.markdown
index 6177d161..a87dbca0 100644
--- a/doc/creating-projects.markdown
+++ b/doc/creating-projects.markdown
@@ -1,9 +1,9 @@
-Creating projects
+Creating Projects
=================
-Kanboard can handle multiple projects. There are two kinds of project:
+Kanboard can handle multiple projects. There are two kinds of projects:
-- Project with mutliple users (you work in team)
+- Team projects
- Private project for a single user
Creating projects for multiple users
diff --git a/doc/creating-tasks.markdown b/doc/creating-tasks.markdown
index afcc5ecb..a4eca794 100644
--- a/doc/creating-tasks.markdown
+++ b/doc/creating-tasks.markdown
@@ -1,4 +1,4 @@
-Creating tasks
+Creating Tasks
==============
From the board, click on the plus sign next to the column name:
@@ -13,14 +13,14 @@ The only mandatory field is the title.
Field description:
-- **Title**: The title of your task, that will be displayed on the board.
+- **Title**: The title of your task, which will be displayed on the board.
- **Description**: Allow you to add more information about the task, the content can be written in [Markdown](http://kanboard.net/documentation/syntax-guide).
-- **Create another task**: Check this box if you want to create a similar task (fields will be prefilled).
+- **Create another task**: Check this box if you want to create a similar task (some fields will be pre-filled).
- **Assignee**: The person that will work on the task.
-- **Category**: Only one category can be assign to a task.
+- **Category**: Only one category can be assigned to a task.
- **Column**: The column where the task will be created, your task will be positioned at the bottom.
- **Color**: Choose the color of the card.
-- **Complexity**: Used in agile project management (Scrum), the complexity or story points is a number that tells the team how hard the story is. Often, people use the fibonacci series.
+- **Complexity**: Used in agile project management (Scrum), the complexity or story points is a number that tells the team how hard the story is. Often, people use the Fibonacci series.
- **Original Estimate**: Estimation in hours to complete the tasks.
- **Due Date**: Overdue tasks will have a red due date and upcoming due dates will be black on the board. Several date format are accepted in addition to the date picker.
diff --git a/doc/cronjob.markdown b/doc/cronjob.markdown
new file mode 100644
index 00000000..32f12888
--- /dev/null
+++ b/doc/cronjob.markdown
@@ -0,0 +1,32 @@
+Background Job Scheduling
+=========================
+
+To work properly, Kanboard requires that a background job run on a daily basis.
+Usually on Unix platforms, this process is done by `cron`.
+
+This background job is necessary for these features:
+
+- Reports and analytics (calculate daily stats of each projects)
+- Send overdue task notifications
+- Execute automatic actions connected to the event "Daily background job for tasks"
+
+Configuration on Unix and Linux platforms
+-----------------------------------------
+
+There are multiple ways to define a cronjob on Unix/Linux operating systems, this example is for Ubuntu 14.04.
+The procedure is similar to other systems.
+
+Edit the crontab of your web server user:
+
+```bash
+sudo crontab -u www-data -e
+```
+
+Example to execute the daily cronjob at 8am:
+
+```bash
+0 8 * * * cd /path/to/kanboard && ./kanboard cronjob >/dev/null 2>&1
+```
+
+Note: the cronjob process must have write access to the database in case you are using Sqlite.
+Usually, running the cronjob under the web server user is enough.
diff --git a/doc/currency-rate.markdown b/doc/currency-rate.markdown
index b959e4d1..c61c8977 100644
--- a/doc/currency-rate.markdown
+++ b/doc/currency-rate.markdown
@@ -1,7 +1,7 @@
Currency Rate
==============
-Since each user can have a predefined hourly rate in different currency.
+Since each user can have a pre-defined hourly rate in different currencies.
If you have to handle multiple currencies, you define here the rate according to the reference currency.
This feature is used for project budget calculation.
diff --git a/doc/custom-filters.markdown b/doc/custom-filters.markdown
index 18bf8584..cb55ab42 100644
--- a/doc/custom-filters.markdown
+++ b/doc/custom-filters.markdown
@@ -1,8 +1,8 @@
Custom Filters
==============
-Custom filters allow you to save any search query.
-In this way, you can extends easily the default filters and save most used search queries.
+Custom filters allow you to save any search query.
+In this way, you can extend the default filters easily and save most used search queries.
- Custom filters are stored by project and associated to the creator.
- If the creator is project manager, he can choose to share the filter with other project members.
@@ -10,10 +10,10 @@ In this way, you can extends easily the default filters and save most used searc
Filter creation
---------------
-Go to the action dropdown or in the project settings and choose **custom filters**:
+Go to the action drop-down or in the project settings and choose **custom filters**:
![Custom Filter Creation](http://kanboard.net/screenshots/documentation/custom-filter-creation.png)
-After creating your filter, it will appears on the board next to the default filters:
+After creating your filter, it will appear on the board next to the default filters:
![Custom Filter Dropdown](http://kanboard.net/screenshots/documentation/custom-filter-dropdown.png)
diff --git a/doc/debian-installation.markdown b/doc/debian-installation.markdown
index 147fe452..ec956049 100644
--- a/doc/debian-installation.markdown
+++ b/doc/debian-installation.markdown
@@ -1,6 +1,8 @@
How to install Kanboard on Debian?
==================================
+Note: Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
+
Debian 8 (Jessie)
-----------------
diff --git a/doc/docker.markdown b/doc/docker.markdown
index fe72a6c8..6bd966d3 100644
--- a/doc/docker.markdown
+++ b/doc/docker.markdown
@@ -2,7 +2,18 @@ How to run Kanboard with Docker?
================================
Kanboard can run easily with [Docker](https://www.docker.com).
-There is a `Dockerfile` in the repository to build your own container.
+
+The image size is approximately **50MB** and contains:
+
+- [Alpine Linux](http://alpinelinux.org/)
+- The [process manager S6](http://skarnet.org/software/s6/)
+- Nginx
+- PHP-FPM
+
+The Kanboard cronjob is also running everyday at midnight.
+URL rewriting is enabled in the included config file.
+
+When the container is running, the memory utilization is around **20MB**.
Use the stable version
----------------------
@@ -17,7 +28,7 @@ docker run -d --name kanboard -p 80:80 -t kanboard/kanboard:stable
Use the development version (automated build)
---------------------------------------------
-Every new commit on the repository trigger a new build on [Docker Hub](https://registry.hub.docker.com/u/kanboard/kanboard/).
+Every new commit on the repository trigger a new build on the [Docker Hub](https://registry.hub.docker.com/u/kanboard/kanboard/).
```bash
docker pull kanboard/kanboard
@@ -29,31 +40,45 @@ The tag **latest** is the **development version** of Kanboard, use at your own r
Build your own Docker image
---------------------------
+There is a `Dockerfile` in the Kanboard repository to build your own image.
Clone the Kanboard repository and run the following command:
```bash
docker build -t youruser/kanboard:master .
```
-To run your image in background on the port 80:
+or
```bash
-docker run -d --name kanboard -p 80:80 -t youruser/kanboard:master
+make docker-image
```
-Store your data on a volume
----------------------------
-
-By default Kanboard will store attachments and the Sqlite database in the directory data. Run this command to use a custom volume path:
+To run your container in background on the port 80:
```bash
-docker run -d --name kanboard -v /your/local/data/folder:/var/www/html/data -p 80:80 -t kanboard/kanboard:master
+docker run -d --name kanboard -p 80:80 -t youruser/kanboard:master
```
+Volumes
+-------
+
+You can attach 2 volumes to your container:
+
+- Data folder: `/var/www/kanboard/data`
+- Plugins folder: `/var/www/kanboard/plugins`
+
+Use the flag `-v` to mount a volume on the host machine like described in [official Docker documentation](https://docs.docker.com/engine/userguide/containers/dockervolumes/).
+
+Config files
+------------
+
+- The container already include a custom config file located at `/var/www/kanboard/config.php`.
+- You can store your own config file on the data volume: `/var/www/kanboard/data/config.php`.
+
References
----------
- [Official Kanboard images](https://registry.hub.docker.com/u/kanboard/kanboard/)
- [Docker documentation](https://docs.docker.com/)
-- [Dockerfile stable version](https://github.com/kanboard/docker/blob/master/Dockerfile)
+- [Dockerfile stable version](https://github.com/kanboard/docker)
- [Dockerfile dev version](https://github.com/fguillot/kanboard/blob/master/Dockerfile)
diff --git a/doc/duplicate-move-tasks.markdown b/doc/duplicate-move-tasks.markdown
index dcb01df5..4451f1df 100644
--- a/doc/duplicate-move-tasks.markdown
+++ b/doc/duplicate-move-tasks.markdown
@@ -17,13 +17,13 @@ Go to the task view and choose **Duplicate to another project**.
![Task Duplication Another Project](http://kanboard.net/screenshots/documentation/task-duplication-another-project.png)
-Only projects where you are member will be shown in the dropdown.
+Only projects where you are members will be shown in the drop-down.
Before to copy the tasks, Kanboard will ask you the destination properties that are not common between the source and destination project.
Basically, you need to define:
-- The destination swimlane
+- The destination swim lane
- The column
- The category
- The assignee
diff --git a/doc/editing-projects.markdown b/doc/editing-projects.markdown
index 7e0fc8ed..781a7cc6 100644
--- a/doc/editing-projects.markdown
+++ b/doc/editing-projects.markdown
@@ -1,4 +1,4 @@
-Editing projects
+Editing Projects
================
Projects can be renamed and disabled at any time.
@@ -9,7 +9,7 @@ To rename a project, just click on the link "Edit project" on the left.
- The start date and end date are used to generate the project Gantt chart
- The description is visible as tooltip on the board and on the projects listing page
-- Administrators and project administrators can convert a private project to a multiple users project by changing the checkbox "Private project".
-- You can also convert a multiple users project to a private project.
+- Administrators and project administrators can convert a private project to multiple users project by changing the checkbox "Private project".
+- You can also convert multiple users project to a private project.
Note: When you make a project private, all existing users will still have access to the project. Adjust the list of users according to your needs.
diff --git a/doc/email-configuration.markdown b/doc/email-configuration.markdown
index 40736c6a..d643fa72 100644
--- a/doc/email-configuration.markdown
+++ b/doc/email-configuration.markdown
@@ -8,7 +8,7 @@ To receive email notifications, users of Kanboard must have:
- Activated notifications in their profile
- Have a valid email address in their profile
-- Be member of the project that will trigger notifications
+- Be a member of the project that will trigger notifications
Note: The logged user who performs the action doesn't receive any notifications, only other project members.
@@ -26,7 +26,7 @@ Server settings
---------------
By default, Kanboard will use the bundled PHP mail function to send emails.
-Usually that require no configuration if your server can already send emails.
+Usually that requires no configuration if your server can already send emails.
However, it's possible to use other methods, the SMTP protocol and Sendmail.
@@ -101,14 +101,14 @@ Examples:
Don't forget the ending slash `/`.
-You need to define that manually because Kanboard cannot guess the URL from a command line script and some people have very specific configuration.
+You need to define that manually because Kanboard cannot guess the URL from a command line script and some people have a very specific configuration.
Troubleshooting
---------------
-If no emails are send and you are sure that everything is configured correctly:
+If no emails are sent and you are sure that everything is configured correctly:
- Check your spam folder
- Enable the debug mode and check the debug file `data/debug.log`, you should see the exact error
-- Be sure that your server or your hosting provider allow you to send emails
+- Be sure that your server or your hosting provider allows you to send emails
- If you use SeLinux, allow PHP to send emails
diff --git a/doc/faq.markdown b/doc/faq.markdown
index 3d542a30..953f98e1 100644
--- a/doc/faq.markdown
+++ b/doc/faq.markdown
@@ -10,25 +10,25 @@ Kanboard works well with any great VPS hosting provider such as [Digital Ocean](
To have the best performances, choose a provider with fast disk I/O because Kanboard use Sqlite by default.
Avoid hosting providers that use a shared NFS mount point.
+
I get a blank page after installing or upgrading Kanboard
---------------------------------------------------------
- Check if you have installed all requirements on your server
-- Check if the files have the correct permissions
-- If you use php-fpm and opcode caching, reload the process to be sure to clear the cache
-- Enable PHP error logging in your php.ini
-- Check the PHP and Apache error logs you should see the exact error
+- Check the PHP and Apache error logs
+- Check if the files have the correct permission
+- If you use an aggressive OPcode caching, reload your web-server or php-fpm
-Page not found and the url seems wrong (&amp;amp;)
-----------------------------------------------
+Page not found and the URL seems wrong (&amp;amp;)
+--------------------------------------------------
-- The url looks like `/?controller=auth&amp;action=login&amp;redirect_query=` instead of `?controller=auth&action=login&redirect_query=`
+- The URL looks like `/?controller=auth&amp;action=login&amp;redirect_query=` instead of `?controller=auth&action=login&redirect_query=`
- Kanboard returns a "Page not found" error
-This issue come from your PHP configuration, the value of `arg_separator.output` is not the PHP's default, there is different ways to fix that:
+This issue comes from your PHP configuration, the value of `arg_separator.output` is not the PHP's default, there is different ways to fix that:
-Change the value directly in your `php.ini` if you have the permission:
+Change the value directly in your `php.ini` if you can:
```
arg_separator.output = "&"
@@ -62,12 +62,12 @@ We recommend to switch to the last version of PHP because it's bundled with [OPc
Why the minimum requirement is PHP 5.3.3?
-----------------------------------------
-Kanboard use the function `password_hash()` to crypt passwords but it's available only for PHP >= 5.5.
+Kanboard uses the function `password_hash()` to crypt passwords but it's available only for PHP >= 5.5.
-However, there is a backport for [older versions of PHP](https://github.com/ircmaxell/password_compat#requirements).
-This library require at least PHP 5.3.7 to work correctly.
+However, there is a back-port for [older versions of PHP](https://github.com/ircmaxell/password_compat#requirements).
+This library requires at least PHP 5.3.7 to work correctly.
-Apparently, Centos and Debian backports security patches so PHP 5.3.3 should be ok.
+Apparently, Centos and Debian back-ports security patches so PHP 5.3.3 should be ok.
Kanboard v1.0.10 and v1.0.11 requires at least PHP 5.3.7 but this change has been reverted to be compatible with PHP 5.3.3 with Kanboard >= v1.0.12
@@ -85,20 +85,6 @@ open http://localhost:8000/
```
-How to migrate my tasks from Wunderlist?
-----------------------------------------
-
-You can use an external tool to import automatically your tasks and lists from Wunderlist to Kanboard.
-
-This is a command line script made by a contributor of Kanboard.
-It's simple, quick and dirty but it works :)
-
-More information here:
-
-- [Wunderlist](http://www.wunderlist.com/)
-- <https://github.com/EpocDotFr/WunderlistToKanboard>
-
-
How to install Kanboard on Yunohost?
------------------------------------
@@ -107,6 +93,18 @@ How to install Kanboard on Yunohost?
There is a [package to install Kanboard on Yunohost easily](https://github.com/mbugeia/kanboard_ynh).
+Where can I find a list of related projects?
+--------------------------------------------
+
+- [Kanboard API python client by @freekoder](https://github.com/freekoder/kanboard-py)
+- [Kanboard Presenter by David Eberlein](https://github.com/davideberlein/kanboard-presenter)
+- [CSV2Kanboard by @ashbike](https://github.com/ashbike/csv2kanboard)
+- [Kanboard for Yunohost by @mbugeia](https://github.com/mbugeia/kanboard_ynh)
+- [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)
+- [Python client script by @dzudek](https://gist.github.com/fguillot/84c70d4928eb1e0cb374)
+
+
Are there some tutorials about Kanboard in other languages?
-----------------------------------------------------------
diff --git a/doc/fr/analytics-tasks.markdown b/doc/fr/analytics-tasks.markdown
index bef7377b..9fef5709 100644
--- a/doc/fr/analytics-tasks.markdown
+++ b/doc/fr/analytics-tasks.markdown
@@ -11,9 +11,9 @@ Lead et cycle time
- Le lead time est la durée entre la création de la tâche et son achèvement (tâche fermée).
- Le cycle time est la durée entre la date de début et l'achèvement.
- Si la tâche n’est pas fermée, l’heure courante est utilisée à la place de la date d'achèvement.
-- Si la date de départ n\'est pas spécifiée, le cycle time n'est pas calculé.
+- Si la date de départ n'est pas spécifiée, le cycle time n'est pas calculé.
-Remarque : vous pouvez configurer une action pour définir automatiquement que la date de départ sera le moment ou vous déplacez une tâche vers une colonne de votre choix
+Remarque : vous pouvez configurer une action pour définir automatiquement que la date de départ sera le moment où vous déplacez une tâche vers une colonne de votre choix
Temps passé dans chaque colonne
---------------------------
diff --git a/doc/fr/duplicate-move-tasks.markdown b/doc/fr/duplicate-move-tasks.markdown
index 6d8e269e..19c86847 100644
--- a/doc/fr/duplicate-move-tasks.markdown
+++ b/doc/fr/duplicate-move-tasks.markdown
@@ -4,7 +4,7 @@ Dupliquer et déplacer des tâches
Dupliquer une tâche dans le même projet
--------------------------------------
-Allez à la vue des par tâche et choisissez **Dupliquer** sur la gauche.
+Allez à la vue par tâche et choisissez **Dupliquer** sur la gauche.
![Duplication de tâche](http://kanboard.net/screenshots/documentation/task-duplication.png)
diff --git a/doc/fr/notifications.markdown b/doc/fr/notifications.markdown
index 2190f317..f49b6495 100644
--- a/doc/fr/notifications.markdown
+++ b/doc/fr/notifications.markdown
@@ -6,7 +6,7 @@ Kanboard est capable d'envoyer des notifications via différents canaux :
- Email
- Web (Liste de message non lus)
-Vous pouvez ajouter d'autres cannaux an ajoutant des extensions comme par exemple Hipchat, Slack ou encore Jabber.
+Vous pouvez ajouter d'autres canaux en ajoutant des extensions comme par exemple Hipchat, Slack ou encore Jabber.
Configuration
--------------
@@ -34,12 +34,12 @@ Vous pouvez aussi sélectionner certain projets, par défaut tous les projets do
Notifications web
-----------------
-Les notifications web sont accéssibles depuis le tableau de bord ou depuis l'icône en haut de la page :
+Les notifications web sont accessibles depuis le tableau de bord ou depuis l'icône en haut de la page :
![Icône des notifications web](http://kanboard.net/screenshots/documentation/web-notifications-icon.png)
-Les notifications sont affichés sous forme de liste. Vous pouvez marquer comme lu chacune d'entre-elle ou toutes en même temps.
+Les notifications sont affichées sous forme de liste. Vous pouvez marquer comme lu chacune d'entre-elle ou toutes en même temps.
![Notifications web](http://kanboard.net/screenshots/documentation/web-notifications.png)
-Avec cette méthode vous pouvez quand même rester avertis de ce que se passe sans pour autant être innonder d'emails.
+Avec cette méthode vous pouvez quand même rester avertis de ce que se passe sans pour autant être inondé d'emails.
diff --git a/doc/fr/recurring-tasks.markdown b/doc/fr/recurring-tasks.markdown
index 98759a98..623f12bf 100644
--- a/doc/fr/recurring-tasks.markdown
+++ b/doc/fr/recurring-tasks.markdown
@@ -3,7 +3,7 @@ Tâches récurrentes
Pour convenir à ma méthodologie de Kanban, les tâches récurrentes ne sont pas basées sur une date mais sur les évènements du tableau.
-- Les tâches récurrentes sont dupliquée dans la première colonne du tableau quand les évènements sélectionnés se produisent
+- Les tâches récurrentes sont dupliquées dans la première colonne du tableau quand les évènements sélectionnés se produisent
- La date d'échéance peut être automatiquement recalculée
- Chaque tâche enregistre l'identifiant de tâche de la tâche parente qui l'a créée et la tâche enfant qui a été créée.
diff --git a/doc/fr/time-tracking.markdown b/doc/fr/time-tracking.markdown
index 0b722c63..be94053b 100644
--- a/doc/fr/time-tracking.markdown
+++ b/doc/fr/time-tracking.markdown
@@ -1,7 +1,7 @@
Suivi du temps
=============
-Les information de la feuille de suivi du temps peuvent être définies au niveau des tâches ou des sous-tâches
+Les informations de la feuille de suivi du temps peuvent être définies au niveau des tâches ou des sous-tâches
Suivi de temps des tâches
------------------
diff --git a/doc/fr/transitions.markdown b/doc/fr/transitions.markdown
index 72106a64..253d090e 100644
--- a/doc/fr/transitions.markdown
+++ b/doc/fr/transitions.markdown
@@ -10,7 +10,7 @@ Depuis la page des tâches, vous pouvez accéder à ces informations:
- Date de l'action
- Colonne d'origine
- Colonne de destination
-- Exécuter (Pour l'utilisateur qui a déplacé la tâche)
+- Exécutant (Pour l'utilisateur qui a déplacé la tâche)
- Temps passé sur la colonne d’origine
Les données de transition entre les tâches peuvent aussi être exportées depuis la page des paramètres du projet
diff --git a/doc/freebsd-installation.markdown b/doc/freebsd-installation.markdown
index 84b35ad8..7b36dff1 100644
--- a/doc/freebsd-installation.markdown
+++ b/doc/freebsd-installation.markdown
@@ -55,7 +55,7 @@ Generally 3 elements have to be installed:
Fetch and extract ports...
```bash
-$ portsnap fetch
+$ portsnap fetch
$ portsnap extract
```
@@ -122,6 +122,7 @@ there is no need to install it manually.
Please note
-----------
-Port is being hosted on [bitbucket](https://bitbucket.org/if0/freebsd-kanboard/). Feel free to comment,
+- Port is being hosted on [bitbucket](https://bitbucket.org/if0/freebsd-kanboard/). Feel free to comment,
fork and suggest updates!
- \ No newline at end of file
+- Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
+
diff --git a/doc/gantt-chart-projects.markdown b/doc/gantt-chart-projects.markdown
index 4dfe95fb..536443e6 100644
--- a/doc/gantt-chart-projects.markdown
+++ b/doc/gantt-chart-projects.markdown
@@ -1,10 +1,10 @@
-Gantt chart for all projects
+Gantt Chart for all projects
============================
The goal of this Gantt chart is to display an overview of all projects based on the start and end dates.
- This Gantt chart is available in the project management section
-- Only project administrators and administrators can access to this section
+- Only project administrators and administrators can access this section
- Project administrators will see only projects where they are members
- Private projects are not shown on this chart
@@ -14,4 +14,4 @@ The goal of this Gantt chart is to display an overview of all projects based on
- Horizontal bars can be resized and moved horizontally with your mouse
- There is no vertical drag and drop
- Project bars are displayed in black when there is no start or end date defined
-- The information tooltip show the list of project managers and standard members
+- The information tooltip shows the list of project managers and standard members
diff --git a/doc/gantt-chart-tasks.markdown b/doc/gantt-chart-tasks.markdown
index ca6521ad..9943f573 100644
--- a/doc/gantt-chart-tasks.markdown
+++ b/doc/gantt-chart-tasks.markdown
@@ -4,7 +4,7 @@ Gantt chart for tasks
The goal of this Gantt chart is to display a time based overview of the tasks for a given project.
- The Gantt chart is available from the "view switcher"
-- Only project managers can access to this section
+- Only project managers can access this section
![Gantt Chart](http://kanboard.net/screenshots/documentation/gantt-chart-project.png)
@@ -13,7 +13,7 @@ The goal of this Gantt chart is to display a time based overview of the tasks fo
- There is no vertical drag and drop
- The bar is the same color as the task
- Each bar display a progression status in percentage, this percentage is calculated by using the column position on the board
-- To fit with the Kanban model, tasks can be ordered by the board positions or by start date
+- To fit with the Kanban model, tasks can be ordered by the board positions or by the start date
- New tasks created from this view will be displayed on the board at the position 1 in the first column
- Tasks are displayed in black when there is no start or due date defined
diff --git a/doc/github-authentication.markdown b/doc/github-authentication.markdown
deleted file mode 100644
index ba0f371f..00000000
--- a/doc/github-authentication.markdown
+++ /dev/null
@@ -1,80 +0,0 @@
-Github Authentication
-=====================
-
-Requirements
-------------
-
-OAuth Github API credentials (available in your [Settings > Applications > Developer applications](https://github.com/settings/applications))
-
-How does this work?
--------------------
-
-The Github authentication in Kanboard uses the [OAuth 2.0](http://oauth.net/2/) protocol, so any user of Kanboard can be linked to a Github account.
-
-That means you can use your Github account to login on Kanboard.
-
-How to link a Github account
-----------------------------
-
-1. Go to your user profile
-2. Click on **External accounts**
-3. Click on the link **Link my Github Account**
-4. You are redirected to the **Github Authorize application form**
-5. Authorize Kanboard by clicking on the button **Accept**
-6. Your account is now linked
-
-Now, on the login page you can be authenticated in one click with the link **Login with my Github Account**.
-
-Your name and email are automatically updated from your Github Account if defined.
-
-Installation instructions
--------------------------
-
-### Setting up OAuth 2.0
-
-- On Github, go to the page [Register a new OAuth application](https://github.com/settings/applications/new)
-- Just follow the [official Github documentation](https://developer.github.com/guides/basics-of-authentication/#registering-your-app)
-- In Kanboard, you can get the **callback url** in **Settings > Integrations > Github Authentication**
-
-### Setting up Kanboard
-
-Either create a new `config.php` file or rename the `config.default.php` file and set the following values:
-
-```php
-// Enable/disable Github authentication
-define('GITHUB_AUTH', true);
-
-// Github client id (Copy it from your settings -> Applications -> Developer applications)
-define('GITHUB_CLIENT_ID', 'YOUR_GITHUB_CLIENT_ID');
-
-// Github client secret key (Copy it from your settings -> Applications -> Developer applications)
-define('GITHUB_CLIENT_SECRET', 'YOUR_GITHUB_CLIENT_SECRET');
-```
-
-### Github Entreprise
-
-To use this authentication method with Github Enterprise you have to change the default urls.
-
-Replace these values by your self-hosted instance of Github:
-
-```php
-// Github oauth2 authorize url
-define('GITHUB_OAUTH_AUTHORIZE_URL', 'https://github.com/login/oauth/authorize');
-
-// Github oauth2 token url
-define('GITHUB_OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token');
-
-// Github API url (don't forget the slash at the end)
-define('GITHUB_API_URL', 'https://api.github.com/');
-```
-
-Notes
------
-
-Kanboard uses these information from your public Github profile:
-
-- Full name
-- Public email address
-- Github unique id
-
-The Github unique id is used to link the local user account and the Github account.
diff --git a/doc/github-webhooks.markdown b/doc/github-webhooks.markdown
deleted file mode 100644
index a20b5a18..00000000
--- a/doc/github-webhooks.markdown
+++ /dev/null
@@ -1,99 +0,0 @@
-Github webhooks integration
-===========================
-
-Kanboard can be synchronized with Github.
-Currently, it's only a one-way synchronization: Github to Kanboard.
-
-Github webhooks are plugged to Kanboard automatic actions.
-When an event occurs on Github, an action can be performed on Kanboard.
-
-List of available events
-------------------------
-
-- Github commit received
-- Github issue opened
-- Github issue closed
-- Github issue reopened
-- Github issue assignee change
-- Github issue label change
-- Github issue comment created
-
-List of available actions
--------------------------
-
-- Create a task from an external provider
-- Change the assignee based on an external username
-- Change the category based on an external label
-- Create a comment from an external provider
-- Close a task
-- Open a task
-
-Configuration on Github
------------------------
-
-Go to your project settings page, on the left choose "Webhooks & Services", then click on the button "Add webhook".
-
-![Github configuration](http://kanboard.net/screenshots/documentation/github-webhooks.png)
-
-- **Payload url**: Copy and paste the link from the Kanboard project settings (section **Integrations > Github**).
-- Select **"Send me everything"**
-
-![Github webhook](http://kanboard.net/screenshots/documentation/kanboard-github-webhooks.png)
-
-Each time an event happens, Github will send an event to Kanboard now.
-The Kanboard webhook url is protected by a random token.
-
-Everything else is handled by automatic actions in your Kanboard project settings.
-
-Examples
---------
-
-### Close a Kanboard task when a commit pushed to Github
-
-- Choose the event: **Github commit received**
-- Choose the action: **Close the task**
-
-When one or more commits are sent to Github, Kanboard will receive the information, each commit message with a task number included will be closed.
-
-Example:
-
-- Commit message: "Fix bug #1234"
-- That will close the Kanboard task #1234
-
-### Create a Kanboard task when a new issue is opened on Github
-
-- Choose the event: **Github issue opened**
-- Choose the action: **Create a task from an external provider**
-
-When a task is created from a Github issue, the link to the issue is added to the description and the task have a new field named "Reference" (this is the Github ticket number).
-
-### Close a Kanboard task when an issue is closed on Github
-
-- Choose the event: **Github issue closed**
-- Choose the action: **Close the task**
-
-### Reopen a Kanboard task when an issue is reopened on Github
-
-- Choose the event: **Github issue reopened**
-- Choose the action: **Open the task**
-
-### Assign a task to a Kanboard user when an issue is assigned on Github
-
-- Choose the event: **Github issue assignee change**
-- Choose the action: **Change the assignee based on an external username**
-
-Note: The username must be the same between Github and Kanboard and the user must be member of the project.
-
-### Assign a category when an issue is tagged on Github
-
-- Choose the event: **Github issue label change**
-- Choose the action: **Change the category based on an external label**
-- Define the label and the category
-
-### Create a comment on Kanboard when an issue is commented on Github
-
-- Choose the event: **Github issue comment created**
-- Choose the action: **Create a comment from an external provider**
-
-If the username is the same between Github and Kanboard the comment author will be assigned, otherwise there is no author.
-The user also have to be member of the project in Kanboard.
diff --git a/doc/gitlab-authentication.markdown b/doc/gitlab-authentication.markdown
deleted file mode 100644
index 8d2f0000..00000000
--- a/doc/gitlab-authentication.markdown
+++ /dev/null
@@ -1,83 +0,0 @@
-Gitlab Authentication
-=====================
-
-Requirements
-------------
-
-- Account on [Gitlab.com](https://gitlab.com) or you own self-hosted Gitlab instance
-- Have Kanboard registered as application in Gitlab
-
-How does this work?
--------------------
-
-The Gitlab authentication in Kanboard uses the [OAuth 2.0](http://oauth.net/2/) protocol, so any user of Kanboard can be linked to a Gitlab account.
-
-That means you can use your Gitlab account to login on Kanboard.
-
-How to link a Gitlab account
-----------------------------
-
-1. Go to your user profile
-2. Click on **External accounts**
-3. Click on the link **Link my Gitlab Account**
-4. You are redirected to the **Gitlab authorization form**
-5. Authorize Kanboard by clicking on the button **Accept**
-6. Your account is now linked
-
-Now, on the login page you can be authenticated in one click with the link **Login with my Gitlab Account**.
-
-Your name and email are automatically updated from your Gitlab Account if defined.
-
-Installation instructions
--------------------------
-
-### Setting up OAuth 2.0
-
-- On Gitlab, register a new application by following the [official documentation](http://doc.gitlab.com/ce/integration/oauth_provider.html)
-- In Kanboard, you can get the **callback url** in **Settings > Integrations > Gitlab Authentication**, just copy and paste the url
-
-### Setting up Kanboard
-
-Either create a new `config.php` file or rename the `config.default.php` file and set the following values:
-
-```php
-// Enable/disable Gitlab authentication
-define('GITLAB_AUTH', true);
-
-// Gitlab application id
-define('GITLAB_CLIENT_ID', 'YOUR_APPLICATION_ID');
-
-// Gitlab application secret
-define('GITLAB_CLIENT_SECRET', 'YOUR_APPLICATION_SECRET');
-```
-
-### Custom endpoints for self-hosted Gitlab
-
-Change these default values if you use a self-hosted instance of Gitlab:
-
-```php
-// Gitlab oauth2 authorize url
-define('GITLAB_OAUTH_AUTHORIZE_URL', 'https://gitlab.com/oauth/authorize');
-
-// Gitlab oauth2 token url
-define('GITLAB_OAUTH_TOKEN_URL', 'https://gitlab.com/oauth/token');
-
-// Gitlab API url endpoint (don't forget the slash at the end)
-define('GITLAB_API_URL', 'https://gitlab.com/api/v3/');
-```
-
-Notes
------
-
-Kanboard uses these information from your Gitlab profile:
-
-- Full name
-- Email address
-- Gitlab unique id
-
-The Gitlab unique id is used to link the local user account and the Gitlab account.
-
-Known issues
-------------
-
-Gitlab OAuth will work only with url rewrite enabled. At the moment, Gitlab doesn't support callback url with query string parameters. See [Gitlab issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/2443)
diff --git a/doc/gitlab-webhooks.markdown b/doc/gitlab-webhooks.markdown
deleted file mode 100644
index 9d9ecaf5..00000000
--- a/doc/gitlab-webhooks.markdown
+++ /dev/null
@@ -1,65 +0,0 @@
-Gitlab webhooks
-===============
-
-Gitlab events can be connected to Kanboard automatic actions.
-
-List of supported events
-------------------------
-
-- Gitlab commit received
-- Gitlab issue opened
-- Gitlab issue closed
-- Gitlab issue comment created
-
-List of supported actions
--------------------------
-
-- Create a task from an external provider
-- Close a task
-- Create a comment from an external provider
-
-Configuration
--------------
-
-![Gitlab configuration](http://kanboard.net/screenshots/documentation/gitlab-webhooks.png)
-
-1. On Kanboard, go to the project settings and choose the section **Integrations**
-2. Copy the Gitlab webhook url
-3. On Gitlab, go to the project settings and go to the section **Webhooks**
-4. Check the boxes **Push Events**, **Comments** and **Issues Events**
-5. Paste the url and save
-
-Examples
---------
-
-### Close a Kanboard task when a commit pushed to Gitlab
-
-- Choose the event: **Gitlab commit received**
-- Choose the action: **Close the task**
-
-When one or more commits are sent to Gitlab, Kanboard will receive the information, each commit message with a task number included will be closed.
-
-Example:
-
-- Commit message: "Fix bug #1234"
-- That will close the Kanboard task #1234
-
-### Create a Kanboard task when a new issue is opened on Gitlab
-
-- Choose the event: **Gitlab issue opened**
-- Choose the action: **Create a task from an external provider**
-
-When a task is created from a Gitlab issue, the link to the issue is added to the description and the task have a new field named "Reference" (this is the Gitlab ticket number).
-
-### Close a Kanboard task when an issue is closed on Gitlab
-
-- Choose the event: **Gitlab issue closed**
-- Choose the action: **Close the task**
-
-### Create a comment on Kanboard when an issue is commented on Gitlab
-
-- Choose the event: **Gitlab issue comment created**
-- Choose the action: **Create a comment from an external provider**
-
-If the username is the same between Gitlab and Kanboard the comment author will be assigned, otherwise there is no author.
-The user also have to be member of the project in Kanboard. \ No newline at end of file
diff --git a/doc/google-authentication.markdown b/doc/google-authentication.markdown
deleted file mode 100644
index 0f4f3ec1..00000000
--- a/doc/google-authentication.markdown
+++ /dev/null
@@ -1,64 +0,0 @@
-Google Authentication
-=====================
-
-Requirements
-------------
-
-OAuth Google API credentials (available in the Google Developer Console)
-
-How does this work?
--------------------
-
-- The Google authentication in Kanboard use the OAuth 2.0 protocol
-- Any user account in Kanboard can be linked to a Google Account
-- When a Kanboard user account is linked to Google, you can login with one click
-
-Procedure to link a Google Account
-----------------------------------
-
-1. Go to your user profile
-2. Click on **External accounts**
-3. Click on the link **Link my Google Account**
-4. You are redirected to the **Google Consent screen**
-5. Authorize Kanboard by clicking on the button **Accept**
-6. Your account is now linked
-
-Now, on the login page you can be authenticated in one click with the link **Login with my Google Account**.
-
-Your name and email are automatically updated from your Google Account.
-
-Installation instructions
--------------------------
-
-### Setting up OAuth 2.0 in Google Developer Console
-
-- Follow the [official Google documentation](https://developers.google.com/accounts/docs/OAuth2Login#appsetup) to create a new application
-- In Kanboard, you can get the **redirect url** in **Settings > Integrations > Google Authentication**
-
-### Setting up Kanboad
-
-Create a custom `config.php` file or copy the `config.default.php` file:
-
-```php
-<?php
-
-// Enable/disable Google authentication
-define('GOOGLE_AUTH', true); // Set this value to true
-
-// Google client id (Get this value from the Google developer console)
-define('GOOGLE_CLIENT_ID', 'YOUR_CLIENT_ID');
-
-// Google client secret key (Get this value from the Google developer console)
-define('GOOGLE_CLIENT_SECRET', 'YOUR_CLIENT_SECRET');
-```
-
-Notes
------
-
-Kanboard use these information from your Google profile:
-
-- Full name
-- Email address
-- Google unique id
-
-The Google unique id is used to link together the local user account and the Google account.
diff --git a/doc/groups.markdown b/doc/groups.markdown
new file mode 100644
index 00000000..16f7ed0b
--- /dev/null
+++ b/doc/groups.markdown
@@ -0,0 +1,17 @@
+Groups Management
+=================
+
+In Kanboard, each user can be a member of one or many groups.
+A group is like a team or an organization.
+
+Only administrators can create new groups and assign users.
+
+Groups can be managed from **User management > View All Groups**.
+From there, you can create groups and assign users.
+
+![Group Management](screenshots/groups-management.png)
+
+Each project manager can authorize the access to a set of groups from the [project permissions page](project-permissions.markdown).
+
+The external id is mainly used for external group providers.
+Kanboard provides a LDAP group provider to [sync automatically groups from LDAP servers](ldap-group-sync.markdown).
diff --git a/doc/heroku.markdown b/doc/heroku.markdown
index ea3c19f9..f145f70e 100644
--- a/doc/heroku.markdown
+++ b/doc/heroku.markdown
@@ -10,7 +10,7 @@ Requirements
------------
- Heroku account, you can use a free account
-- Heroku command line tool installed
+- Heroku command line tools installed
Manual instructions
-------------------
@@ -35,4 +35,5 @@ heroku open
Limitations
-----------
-The storage on Heroku is ephemeral, that means uploaded files through Kanboard are not persistent after a reboot.
+- The storage of Heroku is ephemeral, that means uploaded files through Kanboard are not persistent after a reboot. You may want to install a plugin to store your files in a cloud storage provider like [Amazon S3](https://github.com/kanboard/plugin-s3).
+- Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
diff --git a/doc/ical.markdown b/doc/ical.markdown
index 5c52bf82..fc2318cf 100644
--- a/doc/ical.markdown
+++ b/doc/ical.markdown
@@ -2,24 +2,24 @@ Syncing your calendars
======================
Kanboard supports iCal feeds for projects and users.
-This feature allow you to import Kanboard tasks in almost any calendar program (by example Microsoft Outlook, Apple Calendar, Mozilla Thunderbird and Google Calendar).
+This feature allows you to import Kanboard tasks in almost any calendar program (by example Microsoft Outlook, Apple Calendar, Mozilla Thunderbird and Google Calendar).
-Calendar subscriptions are **read-only** access, you cannot create tasks from an external calendar software.
-The Calendar feed export follow the iCal standard.
+Calendar subscriptions are **read-only** access, you cannot create tasks from external calendar software.
+The Calendar feed export follows the iCal standard.
Note: Only tasks within the date range of -2 months to +6 months are exported to the iCalendar feed.
Project calendars
-----------------
-- Each project have its own calendar.
+- Each project has its own calendar.
- The subscription link is unique per project, the link is activated when you enable the public access of your project: **Project settings > Public access**.
-- This calendar show only tasks for the selected project.
+- This calendar shows only tasks for the selected project.
User calendars
--------------
-- Each user have its own calendar.
+- Each user has its own calendar.
- The subscription link is unique per user, the link is activated when you enable the public access of your user: **User profile > Public access**.
- This calendar show tasks assigned to the user for all projects.
@@ -28,7 +28,7 @@ Adding your Kanboard calendar to Apple Calendar
- Open Calendar
- Select **File > New Calendar Subscription**
-- Copy and paste the iCal feed url from Kanboard
+- Copy and paste the iCal feed URL from Kanboard
![Add iCal subscription](http://kanboard.net/screenshots/documentation/apple-calendar-add-subscription.png)
@@ -44,21 +44,21 @@ Adding your Kanboard calendar to Microsoft Outlook
- Open Outlook
- Select **Open Calendar > From Internet**
-- Copy and paste the iCal feed url from Kanboard
+- Copy and paste the iCal feed URL from Kanboard
![Outlook Edit Internet Calendar](http://kanboard.net/screenshots/documentation/outlook-edit-subscription.png)
Adding your Kanboard calendar to Mozilla Thunderbird
----------------------------------------------------
-- Install the Add-on **Lightning** to add the calendar support to Thunderbird
+- Install the Add-on **Lightning** to add calendar support to Thunderbird
- Click on **File > New Calendar**
- In the dialog box, choose **On the Network**
![Thunderbird Step 1](http://kanboard.net/screenshots/documentation/thunderbird-new-calendar-step1.png)
- Choose the format iCalendar
-- Copy and paste the iCal feed url from Kanboard
+- Copy and paste the iCal feed URL from Kanboard
![Thunderbird Step 2](http://kanboard.net/screenshots/documentation/thunderbird-new-calendar-step2.png)
@@ -69,7 +69,7 @@ Adding your Kanboard calendar to Google Calendar
- Click the down-arrow next to **Other calendars**.
- Select **Add by URL** from the menu.
-- Copy and paste the iCal feed url from Kanboard
+- Copy and paste the iCal feed URL from Kanboard
![Google Calendar](http://kanboard.net/screenshots/documentation/google-calendar-add-subscription.png)
diff --git a/doc/index.markdown b/doc/index.markdown
index 3c0cae97..99ca01f6 100644
--- a/doc/index.markdown
+++ b/doc/index.markdown
@@ -19,6 +19,7 @@ Using Kanboard
### Working with projects
+- [Project Types](project-types.markdown)
- [Creating projects](creating-projects.markdown)
- [Editing projects](editing-projects.markdown)
- [Sharing boards and tasks](sharing-projects.markdown)
@@ -44,9 +45,13 @@ Using Kanboard
- [Create tasks by email](create-tasks-by-email.markdown)
- [Subtasks](subtasks.markdown)
- [Analytics for tasks](analytics-tasks.markdown)
+- [User mentions](user-mentions.markdown)
-### Working with users
+### Working with users and groups
+- [Roles](roles.markdown)
+- [User Types](user-types.markdown)
+- [Group management](groups.markdown)
- [User management](user-management.markdown)
- [Notifications](notifications.markdown)
- [Two factor authentication](2fa.markdown)
@@ -63,9 +68,6 @@ Using Kanboard
### Integrations
-- [Bitbucket webhooks](bitbucket-webhooks.markdown)
-- [Github webhooks](github-webhooks.markdown)
-- [Gitlab webhooks](gitlab-webhooks.markdown)
- [iCalendar subscriptions](ical.markdown)
- [RSS/Atom subscriptions](rss.markdown)
- [Json-RPC API](api-json-rpc.markdown)
@@ -77,7 +79,7 @@ Using Kanboard
- [Advanced Search Syntax](search.markdown)
- [Command line interface](cli.markdown)
- [Syntax guide](syntax-guide.markdown)
-- [Bruteforce protection](bruteforce-protection.markdown)
+- [Brute force protection](bruteforce-protection.markdown)
- [Frequently asked questions](faq.markdown)
Technical details
@@ -85,22 +87,23 @@ Technical details
### Installation
-- [Recommended configuration](recommended-configuration.markdown)
+- [Requirements](requirements.markdown)
- [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 OpenSuse](suse-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)
- [Run Kanboard with Vagrant](vagrant.markdown)
### Configuration
+- [Daily background job](cronjob.markdown)
- [Config file](config.markdown)
- [Email configuration](email-configuration.markdown)
- [URL rewriting](nice-urls.markdown)
@@ -114,10 +117,8 @@ Technical details
### Authentication
- [LDAP authentication](ldap-authentication.markdown)
-- [LDAP group sync](ldap-group-sync.markdown)
-- [Google authentication](google-authentication.markdown)
-- [Github authentication](github-authentication.markdown)
-- [Gitlab authentication](gitlab-authentication.markdown)
+- [LDAP group synchronization](ldap-group-sync.markdown)
+- [LDAP parameters](ldap-parameters.markdown)
- [Reverse proxy authentication](reverse-proxy-authentication.markdown)
### Contributors
diff --git a/doc/installation.markdown b/doc/installation.markdown
index 30a2916c..dd4283f8 100644
--- a/doc/installation.markdown
+++ b/doc/installation.markdown
@@ -1,13 +1,7 @@
Installation
============
-Requirements
-------------
-
-- Apache or Nginx
-- PHP >= 5.3.3 (Kanboard is compatible with PHP 5.3, 5.4, 5.5, 5.6 and 7.0)
-- PHP extensions required: mbstring, gd and pdo_sqlite
-- A modern web browser
+Firstly, check the [requirements](requirements.markdown) before to go further.
From the archive (stable version)
---------------------------------
@@ -27,7 +21,7 @@ The data folder is used to store:
- Uploaded files: `files/*`
- Image thumbnails: `files/thumbnails/*`
-People who are using a remote database (Mysql/Postgresql) and a remote file storage (Aws S3 or similar) don't necessary needs to have a persistent local data folder or to change the permissions.
+People who are using a remote database (Mysql/Postgresql) and a remote file storage (Aws S3 or similar) don't necessarily need to have a persistent local data folder or to change the permission.
From the repository (development version)
-----------------------------------------
@@ -45,3 +39,8 @@ Security
- Don't forget to change the default user/password
- Don't allow everybody to access to the directory `data` from the URL. There is already a `.htaccess` for Apache but nothing for Nginx.
+
+Notes
+-----
+
+- Some features of Kanboard require that you run [a daily background job](cronjob.markdown)
diff --git a/doc/kanban-vs-todo-and-scrum.markdown b/doc/kanban-vs-todo-and-scrum.markdown
index 3d53023a..4e083ff8 100644
--- a/doc/kanban-vs-todo-and-scrum.markdown
+++ b/doc/kanban-vs-todo-and-scrum.markdown
@@ -24,8 +24,8 @@ Kanban vs Scrum
- Estimation is required
- Uses velocity as default metric
- Scrum board is cleared between each sprint
-- Scrum has predefined roles like scrum master, product owner and the team
-- A lot of meetings: planning, backlog grooming, daily stand-up, retrospective
+- Scrum has pre-defined roles like scrum master, product owners and the team
+- A lot of meetings: planning, backlogs grooming, daily stand-up, retrospective
### Kanban:
diff --git a/doc/keyboard-shortcuts.markdown b/doc/keyboard-shortcuts.markdown
index 065d20c2..beb15d3d 100644
--- a/doc/keyboard-shortcuts.markdown
+++ b/doc/keyboard-shortcuts.markdown
@@ -6,6 +6,7 @@ Keyboard shortcuts availability depends of the page you are presently.
Project views (Board, Calendar, List, Gantt)
--------------------------------------------
+- Switch to the project overview = **v o**
- Switch to the board view = **v b** (press on **v** then **b**)
- Switch to the calendar view = **v c**
- Switch to the list view = **v l**
@@ -25,4 +26,4 @@ Application
- Go to the search box = **f**
- Reset the search box = **r**
- Close dialog box = **ESC**
-- Submit a form = **CTRL+ENTER** or **⌘+ENTER** \ No newline at end of file
+- Submit form = **CTRL+ENTER** or **⌘+ENTER**
diff --git a/doc/ldap-authentication.markdown b/doc/ldap-authentication.markdown
index f2e4869a..cacfb523 100644
--- a/doc/ldap-authentication.markdown
+++ b/doc/ldap-authentication.markdown
@@ -1,4 +1,4 @@
-LDAP authentication
+LDAP Authentication
===================
Requirements
@@ -13,28 +13,24 @@ Requirements
Workflow
--------
-When the LDAP authentication is activated, the login process work like that:
+When the LDAP authentication is activated, the login process works like that:
1. Try first to authenticate the user by using the database
2. If the user is not found inside the database, a LDAP authentication is performed
-3. If the LDAP authentication is successful, by default a local user is created automatically with no password and marked as LDAP user.
-
-### Differences between a local user and a LDAP user are the following:
-
-- LDAP users have no local passwords
-- LDAP users can't modify their password with the user interface
+3. If the LDAP authentication is successful, by default a local user is created automatically with no password and marked as LDAP users.
The full name and the email address are automatically fetched from the LDAP server.
-Configuration
--------------
-
-You have to create a custom config file named `config.php` (you can also use the template `config.default.php`).
-This file must be stored in the root directory of Kanboard.
+Authentication Types
+--------------------
-### LDAP bind type
+| Type | Description |
+|------------|-----------------------------------------------------------------|
+| Proxy User | A specific user is used to browse LDAP directory |
+| User | The end-user credentials are used for browsing LDAP directory |
+| Anonymous | No authentication is performed for LDAP browsing |
-There are 3 possible ways to browse the LDAP directory:
+**The recommended authentication method is "Proxy"**.
#### Anonymous mode
@@ -44,7 +40,7 @@ define('LDAP_USERNAME', null);
define('LDAP_PASSWORD', null);
```
-This is the default value but some LDAP servers don't allow that.
+This is the default value but some LDAP servers don't allow anonymous browsing for security reasons.
#### Proxy mode
@@ -60,7 +56,7 @@ define('LDAP_PASSWORD', 'my proxy password');
This method uses the credentials provided by the end-user.
-By example, Microsoft Active Directory doesn't allow anonymous browsing by default and if you don't want to use a proxy user you can use this method.
+For example, Microsoft Active Directory doesn't allow anonymous browsing by default and if you don't want to use a proxy user you can use this method.
```php
define('LDAP_BIND_TYPE', 'user');
@@ -73,7 +69,26 @@ In this case, the constant `LDAP_USERNAME` is used as a pattern to the ldap user
- `%s@kanboard.local` will be replaced by `my_user@kanboard.local`
- `KANBOARD\\%s` will be replaced by `KANBOARD\my_user`
-### Example for Microsoft Active Directory
+User LDAP filter
+----------------
+
+The configuration parameter `LDAP_USER_FILTER` is used to find users in LDAP directory.
+
+Examples:
+
+- `(&(objectClass=user)(sAMAccountName=%s))` is replaced by `(&(objectClass=user)(sAMAccountName=my_username))`
+- `uid=%s` is replaced by `uid=my_username`
+
+Other examples of [filters for Active Directory](http://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx)
+
+Example to filter access to Kanboard:
+
+`(&(objectClass=user)(sAMAccountName=%s)(memberOf=CN=Kanboard Users,CN=Users,DC=kanboard,DC=local))`
+
+This example allows only people members of the group "Kanboard Users" to connect to Kanboard.
+
+Example for Microsoft Active Directory
+--------------------------------------
Let's say we have a domain `KANBOARD` (kanboard.local) and the primary controller is `myserver.kanboard.local`.
@@ -93,8 +108,8 @@ define('LDAP_PASSWORD', 'my super secret password');
define('LDAP_SERVER', 'myserver.kanboard.local');
// LDAP properties
-define('LDAP_ACCOUNT_BASE', 'CN=Users,DC=kanboard,DC=local');
-define('LDAP_USER_PATTERN', '(&(objectClass=user)(sAMAccountName=%s))');
+define('LDAP_USER_BASE_DN', 'CN=Users,DC=kanboard,DC=local');
+define('LDAP_USER_FILTER', '(&(objectClass=user)(sAMAccountName=%s))');
```
Second example with user mode:
@@ -113,11 +128,12 @@ define('LDAP_PASSWORD', null);
define('LDAP_SERVER', 'myserver.kanboard.local');
// LDAP properties
-define('LDAP_ACCOUNT_BASE', 'CN=Users,DC=kanboard,DC=local');
-define('LDAP_USER_PATTERN', '(&(objectClass=user)(sAMAccountName=%s))');
+define('LDAP_USER_BASE_DN', 'CN=Users,DC=kanboard,DC=local');
+define('LDAP_USER_FILTER', '(&(objectClass=user)(sAMAccountName=%s))');
```
-### Example for OpenLDAP
+Example for OpenLDAP
+--------------------
Our LDAP server is `myserver.example.com` and all users are stored under `ou=People,dc=example,dc=com`.
@@ -133,15 +149,14 @@ define('LDAP_AUTH', true);
define('LDAP_SERVER', 'myserver.example.com');
// LDAP properties
-define('LDAP_ACCOUNT_BASE', 'ou=People,dc=example,dc=com');
-define('LDAP_USER_PATTERN', 'uid=%s');
+define('LDAP_USER_BASE_DN', 'ou=People,dc=example,dc=com');
+define('LDAP_USER_FILTER', 'uid=%s');
```
-The `%s` is replaced by the username for the parameter `LDAP_USER_PATTERN`, so you can define a custom Distinguished Name: ` (&(objectClass=user)(uid=%s)(!(ou:dn::=trainees)))`.
-
-### Disable automatic account creation
+Disable automatic account creation
+-----------------------------------
-By default, Kanboard will create automatically a user account if nothing is found.
+By default, Kanboard will create a user account automatically if nothing is found.
You can disable this behavior if you prefer to create user accounts manually to restrict Kanboard to only some people.
@@ -152,77 +167,18 @@ Just change the value of `LDAP_ACCOUNT_CREATION` to `false`:
define('LDAP_ACCOUNT_CREATION', false);
```
+Troubleshootings
+----------------
+
### SELinux restrictions
If SELinux is enabled, you have to allow Apache to reach out your LDAP server.
-- You can switch SELinux to the permissive mode or disable it (not recomemnded)
+- You can switch SELinux to the permissive mode or disable it (not recommended)
- You can allow all network connections, by example `setsebool -P httpd_can_network_connect=1` or have a more restrictive rule
In any case, refer to the official Redhat/Centos documentation.
-### Available configuration parameters
-
-```php
-// Enable LDAP authentication (false by default)
-define('LDAP_AUTH', false);
-
-// LDAP server hostname
-define('LDAP_SERVER', '');
-
-// LDAP server port (389 by default)
-define('LDAP_PORT', 389);
+### Enable debug mode
-// By default, require certificate to be verified for ldaps:// style URL. Set to false to skip the verification
-define('LDAP_SSL_VERIFY', true);
-
-// Enable LDAP START_TLS
-define('LDAP_START_TLS', false);
-
-// LDAP bind type: "anonymous", "user" or "proxy"
-define('LDAP_BIND_TYPE', 'anonymous');
-
-// LDAP username to connect with. null for anonymous bind (default).
-define('LDAP_USERNAME', null);
-
-// LDAP password to connect with. null for anonymous bind (default).
-define('LDAP_PASSWORD', null);
-
-// LDAP account base, i.e. root of all user account
-// Example: ou=People,dc=example,dc=com
-define('LDAP_ACCOUNT_BASE', '');
-
-// LDAP query pattern to use when searching for a user account
-// Example for ActiveDirectory: '(&(objectClass=user)(sAMAccountName=%s))'
-// Example for OpenLDAP: 'uid=%s'
-define('LDAP_USER_PATTERN', '');
-
-// Name of an attribute of the user account object which should be used as the full name of the user.
-define('LDAP_ACCOUNT_FULLNAME', 'displayname');
-
-// Name of an attribute of the user account object which should be used as the email of the user.
-define('LDAP_ACCOUNT_EMAIL', 'mail');
-
-// Name of an attribute of the user account object which should be used as the id of the user.
-// Example for ActiveDirectory: 'samaccountname'
-// Example for OpenLDAP: 'uid'
-define('LDAP_ACCOUNT_ID', '');
-
-// LDAP Attribute for group membership
-define('LDAP_ACCOUNT_MEMBEROF', 'memberof');
-
-// DN for administrators
-// Example: CN=Kanboard Admins,CN=Users,DC=kanboard,DC=local
-define('LDAP_GROUP_ADMIN_DN', '');
-
-// DN for project administrators
-// Example: CN=Kanboard Project Admins,CN=Users,DC=kanboard,DC=local
-define('LDAP_GROUP_PROJECT_ADMIN_DN', '');
-
-// By default Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive)
-// Set to true if you want to preserve the case
-define('LDAP_USERNAME_CASE_SENSITIVE', false);
-
-// Automatically create user account
-define('LDAP_ACCOUNT_CREATION', true);
-```
+If you are not able to setup correctly the LDAP authentication, you can [enable the debug mode](config.markdown) and watch log files.
diff --git a/doc/ldap-group-sync.markdown b/doc/ldap-group-sync.markdown
index 355a1cde..69678a8a 100644
--- a/doc/ldap-group-sync.markdown
+++ b/doc/ldap-group-sync.markdown
@@ -7,30 +7,52 @@ Requirements
- Have LDAP authentication properly configured
- Use a LDAP server that supports `memberOf`
-Automatically define Kanboard groups based on LDAP groups
----------------------------------------------------------
+Define automatically user roles based on LDAP groups
+----------------------------------------------------
-In your config file, define the constants `LDAP_GROUP_ADMIN_DN` and `LDAP_GROUP_PROJECT_ADMIN_DN`. Here an example, replace the values according to your own LDAP configuration:
+Use these constants in your config file:
+
+- `LDAP_GROUP_ADMIN_DN`: Distinguished names for application administrators
+- `LDAP_GROUP_MANAGER_DN`: Distinguished names for application managers
+
+Example:
```php
define('LDAP_GROUP_ADMIN_DN', 'CN=Kanboard Admins,CN=Users,DC=kanboard,DC=local');
-define('LDAP_GROUP_PROJECT_ADMIN_DN', 'CN=Kanboard Project Admins,CN=Users,DC=kanboard,DC=local');
+define('LDAP_GROUP_MANAGER_DN', 'CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local');
```
-- People member of "Kanboard Admins" will be "Kanboard Administrators"
-- People member of "Kanboard Project Admins" will be "Kanboard Project Administrators"
-- Everybody else will be Kanboard Standard Users
+- People member of "Kanboard Admins" will have the role "Administrator"
+- People member of "Kanboard Managers" will have the role "Managers"
+- Everybody else will have the role "User"
+
+Automatically load LDAP groups for project permissions
+------------------------------------------------------
-Note: At the moment, that works only at account creation.
+This feature allows you to sync automatically LDAP groups with Kanboard groups.
+Each group can have a different project role assigned.
-Filter Kanboard access based on the LDAP group
-----------------------------------------------
+On the project permissions page, people can enter groups in the auto-complete field and Kanboard can search for groups with any provider enabled.
-To allow only some users to use Kanboard, use the existing `LDAP_USER_PATTERN` constant:
+If the group doesn't exist in the local database, it will be automatically synced.
+
+- `LDAP_GROUP_PROVIDER`: Enable the LDAP group provider
+- `LDAP_GROUP_BASE_DN`: Distinguished names to find groups in LDAP directory
+- `LDAP_GROUP_FILTER`: LDAP filter used to perform the query
+- `LDAP_GROUP_ATTRIBUTE_NAME`: LDAP attribute used to fetch the group name
+
+Example:
```php
-define('LDAP_USER_PATTERN', '(&(objectClass=user)(sAMAccountName=%s)(memberOf=CN=Kanboard Users,CN=Users,DC=kanboard,DC=local))');
+define('LDAP_GROUP_PROVIDER', true);
+define('LDAP_GROUP_BASE_DN', 'CN=Groups,DC=kanboard,DC=local');
+define('LDAP_GROUP_FILTER', '(&(objectClass=group)(sAMAccountName=%s*))');
```
-This example allow only people member of the group "Kanboard Users" to connect to Kanboard.
+With the filter given as example above, Kanboard will search for groups that match the query.
+If the end-user enter the text "My group" in the auto-complete box, Kanboard will return all groups that match the pattern: `(&(objectClass=group)(sAMAccountName=My group*))`.
+
+- Note 1: The special characters `*` is important here, otherwise an exact match will be done.
+- Note 2: This feature is only compatible with LDAP authentication configured in "proxy" or "anonymous" mode
+[More examples of LDAP filters for Active Directory](http://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx)
diff --git a/doc/ldap-parameters.markdown b/doc/ldap-parameters.markdown
new file mode 100644
index 00000000..bd02baf2
--- /dev/null
+++ b/doc/ldap-parameters.markdown
@@ -0,0 +1,33 @@
+LDAP Configuration Parameters
+=============================
+
+Here is the list of available LDAP parameters:
+
+| Parameter | Default value | Description |
+|---------------------------------|----------------|------------------------------------------------|
+| `LDAP_AUTH` | false | Enable LDAP authentication |
+| `LDAP_SERVER` | Empty | LDAP server hostname |
+| `LDAP_PORT` | 389 | LDAP server port |
+| `LDAP_SSL_VERIFY` | true | Validate certificate for `ldaps://` style URL |
+| `LDAP_START_TLS` | false | Enable LDAP start TLS |
+| `LDAP_USERNAME_CASE_SENSITIVE` | false | Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive) |
+| `LDAP_BIND_TYPE` | anonymous | Bind type: "anonymous", "user" or "proxy" |
+| `LDAP_USERNAME` | null | LDAP username to use with proxy mode or username pattern to use with user mode |
+| `LDAP_PASSWORD` | null | LDAP password to use for proxy mode |
+| `LDAP_USER_BASE_DN` | Empty | LDAP DN for users (Example: "CN=Users,DC=kanboard,DC=local") |
+| `LDAP_USER_FILTER` | Empty | LDAP pattern to use when searching for a user account (Example: "(&(objectClass=user)(sAMAccountName=%s))") |
+| `LDAP_USER_ATTRIBUTE_USERNAME` | uid | LDAP attribute for username (Example: "samaccountname") |
+| `LDAP_USER_ATTRIBUTE_FULLNAME` | cn | LDAP attribute for user full name (Example: "displayname") |
+| `LDAP_USER_ATTRIBUTE_EMAIL` | mail | LDAP attribute for user email |
+| `LDAP_USER_ATTRIBUTE_GROUPS` | memberof | LDAP attribute to find groups in user profile |
+| `LDAP_USER_CREATION` | true | Enable automatic LDAP user creation |
+| `LDAP_GROUP_ADMIN_DN` | Empty | LDAP DN for administrators (Example: "CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local") |
+| `LDAP_GROUP_MANAGER_DN` | Empty | LDAP DN for managers (Example: "CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local") |
+| `LDAP_GROUP_PROVIDER` | false | Enable LDAP group provider for project permissions |
+| `LDAP_GROUP_BASE_DN` | Empty | LDAP Base DN for groups |
+| `LDAP_GROUP_FILTER` | Empty | LDAP group filter (Example: "(&(objectClass=group)(sAMAccountName=%s*))") |
+| `LDAP_GROUP_ATTRIBUTE_NAME` | cn | LDAP attribute for the group name |
+
+Notes:
+
+- LDAP attributes must be in lowercase
diff --git a/doc/link-labels.markdown b/doc/link-labels.markdown
index e6423566..1a53ecfa 100644
--- a/doc/link-labels.markdown
+++ b/doc/link-labels.markdown
@@ -6,6 +6,6 @@ Task relations can be changed from the application settings (**Settings > Link s
![Link Labels](http://kanboard.net/screenshots/documentation/link-labels.png)
Each label may have an opposite label defined.
-If there is no opposite, the label is considered bi-directionnal.
+If there is no opposite, the label is considered bidirectionnal.
![Link Label Creation](http://kanboard.net/screenshots/documentation/link-label-creation.png)
diff --git a/doc/mysql-configuration.markdown b/doc/mysql-configuration.markdown
index 554bec3b..6bfb209d 100644
--- a/doc/mysql-configuration.markdown
+++ b/doc/mysql-configuration.markdown
@@ -45,7 +45,7 @@ Note: You can also rename the template file `config.default.php` to `config.php`
### Importing SQL dump (alternative method)
-The first time, Kanboard will run one by one each database migration and this process can take some time according to your configuration.
+For the first time, Kanboard will run one by one each database migration and this process can take some time according to your configuration.
To avoid any issues or potential timeouts you can initialize the database directly by importing the SQL schema:
@@ -53,5 +53,5 @@ To avoid any issues or potential timeouts you can initialize the database direct
mysql -u root -p my_database < app/Schema/Sql/mysql.sql
```
-The file `app/Schema/Sql/mysql.sql` is a sql dump that represent the last version of the database.
+The file `app/Schema/Sql/mysql.sql` is a sql dump that represents the last version of the database.
diff --git a/doc/nginx-ssl-php-fpm.markdown b/doc/nginx-ssl-php-fpm.markdown
deleted file mode 100644
index 61afd2b1..00000000
--- a/doc/nginx-ssl-php-fpm.markdown
+++ /dev/null
@@ -1,238 +0,0 @@
-Kanboard with Nginx, HTTPS, SPDY and PHP-FPM
-============================================
-
-This installation example will help you to have the following features:
-
-- Latest stable nginx version
-- HTTPS only with a valid certificate
-- [SPDY protocol](http://en.wikipedia.org/wiki/SPDY) activated
-- PHP 5.5 with php-fpm
-- Recommended security parameters
-- File uploads with a 10MB file size limit
-
-This procedure is written for **Ubuntu 14.04 LTS** but it should be similar for any Linux distribution.
-
-For this setup, we suppose that only Kanboard is installed on the server.
-It can be a small virtual machine by example.
-
-Kanboard detect automatically the utilization of HTTPS and enable some extra features:
-
-- [HTTP Strict Transport Security](http://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security)
-- [Secure Cookie Flag](http://en.wikipedia.org/wiki/HTTP_cookie#Secure_and_HttpOnly)
-
-PHP 5.5 Installation
---------------------
-
-```bash
-sudo apt-get install php5-fpm php5-cli php5-sqlite
-```
-
-You can also install `php5-mysql` if you prefer to use Kanboard with Mysql or MariaDB.
-
-Customize your `/etc/php5/fpm/php.ini`:
-
-```ini
-; Security settings
-expose_php = Off
-cgi.fix_pathinfo=0
-
-; Log errors
-error_reporting = E_ALL
-display_errors = Off
-log_errors = On
-html_errors = Off
-error_log = syslog
-
-; File uploads
-upload_max_filesize = 10M
-post_max_size = 10M
-```
-
-Restart PHP background processes:
-
-```bash
-sudo service php5-fpm restart
-```
-
-Nginx Installation
-------------------
-
-We want the latest stable version of nginx to be able to use the SPDY protocol.
-Hopefully, there is PPA for Ubuntu (unofficial):
-
-```bash
-sudo add-apt-repository ppa:nginx/stable
-sudo apt-get install nginx
-```
-
-Generate a SSL certificate
---------------------------
-
-We want a SSL certificate that work everywhere, not a self-signed certificate.
-You can buy a cheap one at [Namecheap](http://www.namecheap.com/?aff=73824) or anywhere else.
-
-Here the different steps to configure your certificate:
-
-```bash
-# Generate a private key
-openssl genrsa -des3 -out kanboard.key 2048
-
-# Create a key with no password for Nginx
-openssl rsa -in kanboard.key -out kanboard.key.nopass
-
-# Generate the Certificate Signing Request, enter your domain name for the field 'Common Name'
-openssl req -new -key kanboard.key.nopass -out kanboard.csr
-
-# Copy and paste the content of the CSR to the Namecheap control panel and finalize the procedure
-cat kanboard.csr
-
-# After that, you receive by email your certificate, then concat everything into a single file
-cat kanboard.crt COMODORSAAddTrustCA.crt COMODORSADomainValidationSecureServerCA.crt AddTrustExternalCARoot.crt > kanboard.pem
-```
-
-Copy the certificates in a new directory:
-
-```bash
-mkdir /etc/nginx/ssl
-cp kanboard.pem /etc/nginx/ssl
-cp kanboard.key.nopass /etc/nginx/ssl
-chmod 400 /etc/nginx/ssl/*
-```
-
-Configure Nginx
----------------
-
-Now, we can customize our installation, start to modify the main configuration file `/etc/nginx/nginx.conf`:
-
-```nginx
-user www-data;
-worker_processes auto;
-pid /run/nginx.pid;
-
-events {
- worker_connections 1024;
-}
-
-http {
- sendfile on;
- tcp_nopush on;
- tcp_nodelay on;
- keepalive_timeout 65;
- types_hash_max_size 2048;
- server_tokens off;
-
- # SSL shared cache between workers
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 10m;
-
- # We disable weak protocols and ciphers
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
- ssl_prefer_server_ciphers on;
- ssl_ciphers HIGH:!SSLv2:!MEDIUM:!LOW:!EXP:!RC4:!DSS:!aNULL:@STRENGTH;
-
- include /etc/nginx/mime.types;
- default_type application/octet-stream;
-
- access_log /var/log/nginx/access.log;
- error_log /var/log/nginx/error.log;
-
- # We enable the Gzip compression for some mime types
- gzip on;
- gzip_disable "msie6";
- gzip_vary on;
- gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
-
- include /etc/nginx/conf.d/*.conf;
- include /etc/nginx/sites-enabled/*;
-}
-```
-
-Create a new virtual host for Kanboard `/etc/nginx/sites-available/kanboard`
-
-
-```nginx
-server {
- # We also enable the SPDY protocol
- listen 443 ssl spdy;
-
- # Our SSL certificate
- ssl on;
- ssl_certificate /etc/nginx/ssl/kanboard.pem;
- ssl_certificate_key /etc/nginx/ssl/kanboard.key.nopass;
-
- # You can change the default root directory here
- root /usr/share/nginx/html;
-
- index index.php;
-
- # Your domain name
- server_name localhost;
-
- # The maximum body size, useful for file uploads
- client_max_body_size 10M;
-
- location / {
- try_files $uri $uri/ =404;
- }
-
- error_page 404 /404.html;
- error_page 500 502 503 504 /50x.html;
- location = /50x.html {
- root /usr/share/nginx/html;
- }
-
- # PHP-FPM configuration
- location ~ \.php$ {
- try_files $uri =404;
- fastcgi_split_path_info ^(.+\.php)(/.+)$;
- fastcgi_pass unix:/var/run/php5-fpm.sock;
- fastcgi_index index.php;
- include fastcgi.conf;
- }
-
- # Deny access to the directory data
- location ~* /data {
- deny all;
- return 404;
- }
-
- # Deny access to .htaccess
- location ~ /\.ht {
- deny all;
- return 404;
- }
-}
-```
-
-Now it's time to test our setup
-
-```bash
-# Disable the default virtual host
-sudo unlink /etc/nginx/sites-enabled/default
-
-# Add our default virtual host
-sudo ln -s /etc/nginx/sites-available/kanboard /etc/nginx/sites-enabled/kanboard
-
-# Check the config file
-sudo nginx -t
-nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
-nginx: configuration file /etc/nginx/nginx.conf test is successful
-
-# Restart nginx
-sudo service nginx restart
-```
-
-Kanboard Installation
----------------------
-
-You can install Kanboard in a subdirectory or not, it's up to you.
-
-```bash
-cd /usr/share/nginx/html
-sudo wget http://kanboard.net/kanboard-latest.zip
-sudo unzip kanboard-latest.zip
-sudo chown -R www-data:www-data kanboard/data
-sudo rm kanboard-latest.zip
-```
-
-Now, you should be able to use Kanboard with your web browser.
diff --git a/doc/nice-urls.markdown b/doc/nice-urls.markdown
index 82292889..9fbb3510 100644
--- a/doc/nice-urls.markdown
+++ b/doc/nice-urls.markdown
@@ -1,12 +1,12 @@
URL rewriting
=============
-Kanboard is able to work indifferently with url rewriting enabled or not.
+Kanboard is able to work indifferently with URL rewriting enabled or not.
- Example of URL rewritten: `/board/123`
- Otherwise: `?controller=board&action=show&project_id=123`
-If you use Kanboard with Apache and with the mode rewrite enabled, nice urls will be used automatically.
+If you use Kanboard with Apache and with the mode rewrite enabled, nice URLs will be used automatically.
In case you get a "404 Not Found", you might need to set at least the following overrides for your DocumentRoot to get the .htaccess files working:
```sh
@@ -29,7 +29,7 @@ Configuration
By default, Kanboard will check if the Apache mode rewrite is enabled.
-To avoid the automatic detection of url rewriting from the web server, you can enable this feature in your config file:
+To avoid the automatic detection of URL rewriting from the web server, you can enable this feature in your config file:
```php
define('ENABLE_URL_REWRITE', true);
@@ -38,9 +38,9 @@ define('ENABLE_URL_REWRITE', true);
When this constant is at `true`:
- URLs generated from command line tools will be also converted
-- If you use another web server than Apache, by example Nginx or Microsoft IIS, you have to configure yourself the url rewriting
+- If you use another web server than Apache, by example Nginx or Microsoft IIS, you have to configure yourself the URL rewriting
-Note: Kanboard always fallback to old school urls when it's not configured, this configuration is optional.
+Note: Kanboard always fallback to old school URLs when it's not configured, this configuration is optional.
Nginx configuration example
---------------------------
@@ -51,7 +51,7 @@ In the section `server` of your Nginx config file you can use this example:
index index.php;
location / {
- try_files $uri $uri/ /index.php;
+ try_files $uri $uri/ /index.php$is_args$args;
# If Kanboard is under a subfolder
# try_files $uri $uri/ /kanboard/index.php;
@@ -61,6 +61,7 @@ location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php5-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
include fastcgi_params;
}
@@ -85,3 +86,37 @@ define('ENABLE_URL_REWRITE', true);
```
Adapt the example above according to your own configuration.
+
+IIS configuration example
+---------------------------
+
+Create a web.config in you installation folder:
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+ <system.webServer>
+ <rewrite>
+ <rules>
+ <rule name="Imported Rule 1" stopProcessing="true">
+ <match url="^" ignoreCase="false" />
+ <conditions logicalGrouping="MatchAll">
+ <add input="{REQUEST_FILENAME}" matchType="IsFile" ignoreCase="false" negate="true" />
+ </conditions>
+ <action type="Rewrite" url="index.php" appendQueryString="true" />
+ </rule>
+ </rules>
+ </rewrite>
+ </system.webServer>
+</configuration>
+
+```
+
+In your Kanboard `config.php`:
+
+```php
+define('ENABLE_URL_REWRITE', true);
+```
+
+Adapt the example above according to your own configuration.
+
diff --git a/doc/notifications.markdown b/doc/notifications.markdown
index 55ba94bb..b69696fa 100644
--- a/doc/notifications.markdown
+++ b/doc/notifications.markdown
@@ -13,7 +13,7 @@ Configuration
Each user must enable the notifications in their profile: **User Profile > Notifications**. It's disabled by default.
-To receive email notifications you need a valid email address in you profile and the application must be configured to send emails.
+To receive email notifications you need a valid email address in your profile and the application must be configured to send emails.
![Notifications](http://kanboard.net/screenshots/documentation/notifications.png)
@@ -22,14 +22,14 @@ You can choose your favorite notification method:
- Emails
- Web (see below)
-For each project your are member, you can choose to receive notifications for:
+For each project you are a member, you can choose to receive notifications for:
- All tasks
- Only for tasks assigned to you
- Only for tasks created by you
- Only for tasks created by you and assigned to you
-You can also select only some projects, by default it's all projects where you are member.
+You can also select only some projects, by default it's all projects where you are a member.
Web notifications
-----------------
diff --git a/doc/plugin-authentication-architecture.markdown b/doc/plugin-authentication-architecture.markdown
new file mode 100644
index 00000000..d357c933
--- /dev/null
+++ b/doc/plugin-authentication-architecture.markdown
@@ -0,0 +1,99 @@
+Authentication Architecture
+===========================
+
+Kanboard provides a flexible and pluggable authentication architecture.
+
+By default, user authentication can be done with multiple methods:
+
+- Username and password authentication (Local database and LDAP)
+- OAuth2 authentication
+- Reverse-Proxy authentication
+- Cookie based authentication (Remember Me)
+
+More over, after a successful authentication, a Two-Factor post authentication can be done.
+Kanboard supports natively the TOTP standard.
+
+Authentication Interfaces
+-------------------------
+
+To have a pluggable system, authentication drivers must implement a set of interfaces:
+
+| Interface | Role |
+|------------------------------------------|------------------------------------------------------------------|
+| AuthenticationProviderInterface | Base interface for other authentication interfaces |
+| PreAuthenticationProviderInterface | The user is already authenticated when reaching the application, web servers usually define some environment variables |
+| PasswordAuthenticationProviderInterface | Authentication methods that uses the username and password provided in the login form |
+| OAuthAuthenticationProviderInterface | OAuth2 providers |
+| PostAuthenticationProviderInterface | Two-Factor auhentication drivers, ask for confirmation code |
+| SessionCheckProviderInterface | Providers that are able to check if the user session is valid |
+
+### Examples of authentication providers:
+
+- The default Database method implements `PasswordAuthenticationProviderInterface` and `SessionCheckProviderInterface`
+- The Reverse-Proxy method implements `PreAuthenticationProviderInterface` and `SessionCheckProviderInterface`
+- The Google method implements `OAuthAuthenticationProviderInterface`
+- The LDAP method implements `PasswordAuthenticationProviderInterface`
+- The RememberMe cookie method implements `PreAuthenticationProviderInterface`
+- The Two-Factor TOTP method implements `PostAuthenticationProviderInterface`
+
+Authentication Workflow
+-----------------------
+
+For each HTTP request:
+
+1. If the user session is already open, execute registered providers that implements `SessionCheckProviderInterface`
+2. Execute all providers that implements `PreAuthenticationProviderInterface`
+3. If the end-user submit the login form, providers that implements `PasswordAuthenticationProviderInterface` are executed
+4. If the end-user wants to use OAuth2, the selected provider will be executed
+5. After a successful authentication, the last registered `PostAuthenticationProviderInterface` will be used
+6. Synchronize user information if necessary
+
+This workflow is managed by the class `Kanboard\Core\Security\AuthenticationManager`.
+
+Events triggered:
+
+- `AuthenticationManager::EVENT_SUCCESS`: Successful authentication
+- `AuthenticationManager::EVENT_FAILURE`: Failed authentication
+
+Each time a failure event occurs, the counter of failed logins is incremented.
+
+The user account can be locked down for the configured period of time and a captcha can be shown to avoid brute force attacks.
+
+User Provider Interface
+-----------------------
+
+When the authentication is successful, the `AuthenticationManager` will ask the user information to your driver by calling the method `getUser()`.
+This method must return an object that implements the interface `Kanboard\Core\User\UserProviderInterface`.
+
+This class abstract the information gathered from another system.
+
+Examples:
+
+- `DatabaseUserProvider` provides information for an internal user
+- `LdapUserProvider` for a LDAP user
+- `ReverseProxyUserProvider` for a Reverse-Proxy user
+- `GoogleUserProvider` represents a Google user
+
+Methods for User Provider Interface:
+
+- `isUserCreationAllowed()`: Return true to allow automatic user creation
+- `getExternalIdColumn()`: Get external id column name (google_id, github_id, gitlab_id...)
+- `getInternalId()`: Get internal database id
+- `getExternalId()`: Get external id (Unique id)
+- `getRole()`: Get user role
+- `getUsername()`: Get username
+- `getName()`: Get user full name
+- `getEmail()`: Get user email address
+- `getExternalGroupIds()`: Get external group ids, automatically sync group membership if present
+- `getExtraAttributes()`: Get extra attributes to set for the user during the local sync
+
+It's not mandatory to return a value for each method.
+
+User Local Synchronization
+--------------------------
+
+User information can be automatically synced with the local database.
+
+- If the method `getInternalId()` return a value no synchronization is performed
+- The methods `getExternalIdColumn()` and `getExternalId()` must return a value to sync the user
+- Properties that returns an empty string won't be synced
diff --git a/doc/plugin-authentication.markdown b/doc/plugin-authentication.markdown
new file mode 100644
index 00000000..06fdfd8d
--- /dev/null
+++ b/doc/plugin-authentication.markdown
@@ -0,0 +1,40 @@
+Authentication Plugin
+=====================
+
+New authentication backends can be written with very few lines of code.
+
+Provider Registration
+---------------------
+
+In the method `initialize()` of your plugin, call the method `register()` of the class `AuthenticationManager`:
+
+```php
+public function initialize()
+{
+ $this->authenticationManager->register(new ReverseProxyLdapAuth($this->container));
+}
+```
+
+The object provided to the method `register()` must implement one of the pre-defined authentication interfaces.
+
+Those interfaces are defined in the namepsace `Kanboard\Core\Security`:
+
+- `Kanboard\Core\Security\PreAuthenticationProviderInterface`
+- `Kanboard\Core\Security\PostAuthenticationProviderInterface`
+- `Kanboard\Core\Security\PasswordAuthenticationProviderInterface`
+- `Kanboard\Core\Security\OAuthAuthenticationProviderInterface`
+
+The only requirement is to implement the interfaces, you class can be written the way you want and located anywhere on the disk.
+
+User Provider
+-------------
+
+When the authentication is successful, your driver must return an object that represents the user.
+This object must implement the interface `Kanboard\Core\User\UserProviderInterface`.
+
+Example of authentication plugins
+---------------------------------
+
+- [Authentication providers included in Kanboard](https://github.com/fguillot/kanboard/tree/master/app/Auth)
+- [Reverse-Proxy Authentication with LDAP support](https://github.com/kanboard/plugin-reverse-proxy-ldap)
+- [SMS Two-Factor Authentication](https://github.com/kanboard/plugin-sms-2fa)
diff --git a/doc/plugin-authorization-architecture.markdown b/doc/plugin-authorization-architecture.markdown
new file mode 100644
index 00000000..24acee17
--- /dev/null
+++ b/doc/plugin-authorization-architecture.markdown
@@ -0,0 +1,39 @@
+Authorization Architecture
+==========================
+
+Kanboard [supports multiple roles](roles.markdown) at the application level and at the project level.
+
+Authorization Workflow
+----------------------
+
+For each HTTP request:
+
+1. Authorize or not access to the resource based on the application access list
+2. If the resource is for a project (board, task...):
+ 1. Fetch user role for this project
+ 2. Grant/Denied access based on the project access map
+
+Extending Access Map
+--------------------
+
+The Access List (ACL) is based on the controller class name and the method name.
+The list of access is handled by the class `Kanboard\Core\Security\AccessMap`.
+
+There are two access map: one for the application and another one for projects.
+
+- Application access map: `$this->applicationAccessMap`
+- Project access map: `$this->projectAccessMap`
+
+Examples to define a new policy from your plugin:
+
+```php
+// All methods of the class MyController:
+$this->projectAccessMap->add('MyController', '*', Role::PROJECT_MANAGER);
+
+// All some methods:
+$this->projectAccessMap->add('MyOtherController', array('create', 'save'), Role::PROJECT_MEMBER);
+```
+
+Roles are defined in the class `Kanboard\Core\Security\Role`.
+
+The Authorization class (`Kanboard\Core\Security\Authorization`) will check the access for each page.
diff --git a/doc/plugin-automatic-actions.markdown b/doc/plugin-automatic-actions.markdown
new file mode 100644
index 00000000..b309fac9
--- /dev/null
+++ b/doc/plugin-automatic-actions.markdown
@@ -0,0 +1,60 @@
+Adding Automatic Actions
+========================
+
+Adding a new automatic action is pretty simple.
+
+Creating a new action
+---------------------
+
+Your automatic action must inherit of the class `Kanboard\Action\Base`.
+Several abstract methods must be implemented by yourself:
+
+| Method | Description |
+|-------------------------------------|------------------------------------------------------------------|
+| `getDescription()` | Description visible in the user interface |
+| `getCompatibleEvents()` | Get the list of compatible events |
+| `getActionRequiredParameters()` | Get the required parameter for the action (defined by the user) |
+| `getEventRequiredParameters()` | Get the required parameter for the event |
+| `doAction(array $data)` | Execute the action, must return true on success |
+| `hasRequiredCondition(array $data)` | Check if the event data meet the action condition |
+
+Your automatic action is identified in Kanboard by using the absolute class name with the name space included.
+
+Adding new events
+-----------------
+
+The list of application events is available in the class `Kanboard\Core\Event\EventManager::getAll()`.
+However, if your plugin fires new events, you can register these events like that:
+
+```php
+$this->actionManager->getAction('\Kanboard\Plugin\MyPlugin\MyActionName')->addEvent('my.event', 'My event description');
+```
+
+You can extend the list of compatible events of existing actions by using the same method.
+
+Registering the action
+----------------------
+
+You have to call the method `register()` from the class `Kanboard\Core\Action\ActionManager`:
+
+```php
+<?php
+
+namespace Kanboard\Plugin\AutomaticAction;
+
+use Kanboard\Core\Plugin\Base;
+use Kanboard\Plugin\AutomaticAction\Action\TaskRename;
+
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $this->actionManager->register(new TaskRename($this->container));
+ }
+}
+```
+
+Example
+-------
+
+- [Automatic Action example](https://github.com/kanboard/plugin-example-automatic-action)
diff --git a/doc/plugin-events.markdown b/doc/plugin-events.markdown
new file mode 100644
index 00000000..f4db8ff3
--- /dev/null
+++ b/doc/plugin-events.markdown
@@ -0,0 +1,27 @@
+Using Events
+============
+
+Kanboard use internally the [Symfony EventDispatcher component](https://symfony.com/doc/2.3/components/event_dispatcher/index.html) to manage internal events.
+
+Event Listening
+---------------
+
+```php
+$this->on('app.bootstrap', function($container) {
+ // Do something
+});
+```
+
+- The first argument is the event name (string)
+- The second argument is a PHP callable function (closure or class method)
+
+Adding a new event
+------------------
+
+To add a new event, you have to call the method `register()` of the class `Kanboard\Core\Event\EventManager`:
+
+```php
+$this->eventManager->register('my.event.name', 'My new event description');
+```
+
+These events can be used by other components of Kanboard like automatic actions.
diff --git a/doc/plugin-external-link.markdown b/doc/plugin-external-link.markdown
new file mode 100644
index 00000000..36252aff
--- /dev/null
+++ b/doc/plugin-external-link.markdown
@@ -0,0 +1,78 @@
+External Link Providers
+=======================
+
+This functionality allows you to link a task to additional items stored on another system.
+
+For example, you can link a task to:
+
+- Traditional web page
+- Attachment (PDF documents stored on the web, archive...)
+- Any ticketing system (bug tracker, customer support ticket...)
+
+Each item has a type, a URL, a dependency type and a title.
+
+By default, Kanboard includes two kinds of providers:
+
+- Web Link: You copy and paste a link and Kanboard will fetch the page title automatically
+- Attachment: Link to anything that is not a web page
+
+Workflow
+--------
+
+1. The end-user copy and paste the URL to the form and submit
+2. If the link type is "auto", Kanboard will loop through all providers registered until there is a match
+3. Then, the link provider returns a object that implements the interface `ExternalLinkInterface`
+4. A form is shown to the user with all pre-filled data before to save the link
+
+Interfaces
+----------
+
+To implement a new link provider from a plugin, you need to create 2 classes that implement those interfaces:
+
+- `Kanboard\Core\ExternalLink\ExternalLinkProviderInterface`
+- `Kanboard\Core\ExternalLink\ExternalLinkInterface`
+
+### ExternalLinkProviderInterface
+
+| Method | Usage |
+|----------------------------|-----------------------------------------------------------------|
+| `getName()` | Get provider name (label) |
+| `getType()` | Get link type (will be saved in the database) |
+| `getDependencies()` | Get a dictionary of supported dependency types by the provider |
+| `setUserTextInput($input)` | Set text entered by the user |
+| `match()` | Return true if the provider can parse correctly the user input |
+| `getLink()` | Get the link found with the properties |
+
+### ExternalLinkInterface
+
+| Method | Usage |
+|-------------------|------------------|
+| `getTitle()` | Get link title |
+| `getUrl()` | Get link URL |
+| `setUrl($url)` | Set link URL |
+
+Register a new link provider
+----------------------------
+
+In your `Plugin.php`, just call the method `register()` from the object `ExternalLinkManager`:
+
+```php
+<?php
+
+namespace Kanboard\Plugin\MyExternalLink;
+
+use Kanboard\Core\Plugin\Base;
+
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $this->externalLinkManager->register(new MyLinkProvider());
+ }
+}
+```
+
+Examples
+--------
+
+- Kanboard includes the default providers "WebLink" and "Attachment"
diff --git a/doc/plugin-group-provider.markdown b/doc/plugin-group-provider.markdown
new file mode 100644
index 00000000..4d73b740
--- /dev/null
+++ b/doc/plugin-group-provider.markdown
@@ -0,0 +1,55 @@
+Custom Group Providers
+======================
+
+Kanboard is able to load groups from an external system.
+This feature is mainly used for project permissions.
+
+Project managers can allow access to a project for a group.
+The end-user will use an auto-complete box and search for a group.
+
+Each time a group query is executed, all registered group providers are executed.
+
+Group Provider Workflow
+-----------------------
+
+1. The end-user start to type the group name in the auto-complete field
+2. The `GroupManager` class will execute the query across all registered group providers
+3. Results are merged and returned to the user interface
+4. After selecting a group, the information of the group are synced to the local database if necessary
+
+Group Provider Interface
+------------------------
+
+Interface to implement: `Kanboard\Core\Group\GroupProviderInterface`.
+
+Classes that implements this interface abstract the group information, there are only 3 methods:
+
+- `getInternalId()`: Get internal database id, return 0 otherwise
+- `getExternalId()`: Get external unique id
+- `getName()`: Get group name
+
+Kanboard will use the external id to sync with the local database.
+
+Group Backend Provider Interface
+--------------------------------
+
+Interface to implement: `Kanboard\Core\Group\GroupBackendProviderInterface`.
+
+This interface requires only one method: `find($input)`.
+The argument `$input` is the text entered from the user interface.
+
+This method must return a list of `GroupProviderInterface`, this is the result of the search.
+
+Backend Registration from Plugins
+---------------------------------
+
+In the method `initialize()` of your plugin register your custom backend like that:
+
+```php
+$groupManager->register(new MyCustomLdapBackendGroupProvider($this->container));
+```
+
+Examples
+--------
+
+- [Group providers included in Kanboard (LDAP and Database)](https://github.com/fguillot/kanboard/tree/master/app/Group)
diff --git a/doc/plugin-hooks.markdown b/doc/plugin-hooks.markdown
index eab2fa15..3dcb5b1f 100644
--- a/doc/plugin-hooks.markdown
+++ b/doc/plugin-hooks.markdown
@@ -16,13 +16,13 @@ $this->hook->on('hook_name', $callable);
The first argument is the name of the hook and the second is a PHP callable.
-### Hooks executed only one time
+### Hooks executed only once
Some hooks can have only one listener:
#### model:subtask-time-tracking:calculate:time-spent
-- Override time spent calculation when subtask timer is stopped
+- Override time spent calculation when sub-task timer is stopped
- Arguments:
- `$user_id` (integer)
- `$start` (DateTime)
@@ -58,7 +58,27 @@ class Plugin extends Base
}
```
-List of merge hooks:
+Example to override default values for task forms:
+
+```php
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $this->hook->on('controller:task:form:default', function (array $default_values) {
+ return empty($default_values['score']) ? array('score' => 4) : array();
+ });
+ }
+}
+```
+
+List of merging hooks:
+
+#### controller:task:form:default
+
+- Override default values for task forms
+- Arguments:
+ - `$default_values`: actual default values (array)
#### controller:calendar:project:events
@@ -79,7 +99,7 @@ List of merge hooks:
Asset Hooks
-----------
-Asset hooks can be used to add easily a new stylesheet or a new javascript file in the layout. You can use this feature to create a theme and override all Kanboard default styles.
+Asset hooks can be used to add a new stylesheet easily or a new JavaScript file in the layout. You can use this feature to create a theme and override all Kanboard default styles.
Example to add a new stylesheet:
@@ -127,26 +147,40 @@ Example with `myplugin:dashboard/sidebar`:
- On the filesystem, the plugin will be located here: `plugins\Myplugin\Template\dashboard\sidebar.php`
- Templates are written in pure PHP (don't forget to escape data)
-Template name without prefix are core templates.
+Template names without prefix are core templates.
List of template hooks:
-- `template:auth:login-form:before`
-- `template:auth:login-form:after`
-- `template:dashboard:sidebar`
-- `template:config:sidebar`
-- `template:config:integrations`
-- `template:project:integrations`
-- `template:user:integrations`
-- `template:export:sidebar`
-- `template:layout:head`
-- `template:layout:top`
-- `template:layout:bottom`
-- `template:project:dropdown`
-- `template:project-user:sidebar`
-- `template:task:sidebar:information`
-- `template:task:sidebar:actions`
-- `template:user:sidebar:information`
-- `template:user:sidebar:actions`
-
-Other template hooks can be added if necessary, just ask on the issue tracker.
+| Hook | Description |
+|--------------------------------------|----------------------------------------------------|
+| `template:analytic:sidebar` | Sidebar on analytic pages |
+| `template:app:filters-helper:before` | Filter helper dropdown (top) |
+| `template:app:filters-helper:after` | Filter helper dropdown (bottom) |
+| `template:auth:login-form:before` | Login page (top) |
+| `template:auth:login-form:after` | Login page (bottom) |
+| `template:config:sidebar` | Sidebar on settings page |
+| `template:config:integrations` | Integration page in global settings |
+| `template:dashboard:sidebar` | Sidebar on dashboard page |
+| `template:export:sidebar` | Sidebar on export pages |
+| `template:layout:head` | Page layout `<head/>` tag |
+| `template:layout:top` | Page layout top header |
+| `template:layout:bottom` | Page layout footer |
+| `template:project:dropdown` | "Actions" menu on left in different project views |
+| `template:project:header:before` | Project filters (before) |
+| `template:project:header:after` | Project filters (after) |
+| `template:project:integrations` | Integration page in projects settings |
+| `template:project:sidebar` | Sidebar in project settings |
+| `template:project-user:sidebar` | Sidebar on project user overview page |
+| `template:task:menu` | "Actions" menu on left in different task views |
+| `template:task:dropdown` | Task dropdown menu in listing pages |
+| `template:task:sidebar` | Sidebar on task page |
+| `template:task:form:right-column` | Right column in task form |
+| `template:user:authentication:form` | "Edit authentication" form in user profile |
+| `template:user:create-remote:form` | "Create remote user" form |
+| `template:user:external` | "External authentication" page in user profile |
+| `template:user:integrations` | Integration page in user profile |
+| `template:user:sidebar:actions` | Sidebar in user profile (section actions) |
+| `template:user:sidebar:information` | Sidebar in user profile (section information) |
+
+
+Another template hooks can be added if necessary, just ask on the issue tracker.
diff --git a/doc/plugin-ldap-client.markdown b/doc/plugin-ldap-client.markdown
new file mode 100644
index 00000000..312eea71
--- /dev/null
+++ b/doc/plugin-ldap-client.markdown
@@ -0,0 +1,99 @@
+LDAP Library
+============
+
+To facilitate LDAP integration, Kanboard has its own LDAP library.
+This library can perform common operations.
+
+Client
+------
+
+Class: `Kanboard\Core\Ldap\Client`
+
+To connect to your LDAP server easily, use this method:
+
+```php
+use Kanboard\Core\Ldap\Client as LdapClient;
+use Kanboard\Core\Ldap\ClientException as LdapException;
+
+try {
+ $client = LdapClient::connect();
+
+ // Get native LDAP resource
+ $resource = $client->getConnection();
+
+ // ...
+
+} catch (LdapException $e) {
+ // ...
+}
+```
+
+LDAP Queries
+------------
+
+Classes:
+
+- `Kanboard\Core\Ldap\Query`
+- `Kanboard\Core\Ldap\Entries`
+- `Kanboard\Core\Ldap\Entry`
+
+Example to query the LDAP directory:
+
+```php
+
+$query = new Query($client)
+$query->execute('ou=People,dc=kanboard,dc=local', 'uid=my_user', array('cn', 'mail'));
+
+if ($query->hasResult()) {
+ $entries = $query->getEntries(); // Return an instance of Entries
+}
+```
+
+Read one entry:
+
+```php
+$firstEntry = $query->getEntries()->getFirstEntry();
+$email = $firstEntry->getFirstValue('mail');
+$name = $firstEntry->getFirstValue('cn', 'Default Name');
+```
+
+Read multiple entries:
+
+```php
+foreach ($query->getEntries()->getAll() as $entry) {
+ $emails = $entry->getAll('mail'); // Fetch all emails
+ $dn = $entry->getDn(); // Get LDAP DN of this user
+
+ // Check if a value is present for an attribute
+ if ($entry->hasValue('mail', 'user2@localhost')) {
+ // ...
+ }
+}
+```
+
+User Helper
+-----------
+
+Class: `Kanboard\Core\Ldap\User`
+
+Fetch a single user in one line:
+
+```php
+// Return an instance of LdapUserProvider
+$user = User::getUser($client, 'my_username');
+```
+
+Group Helper
+------------
+
+Class: `Kanboard\Core\Ldap\Group`
+
+Fetch groups in one line:
+
+```php
+// Define LDAP filter
+$filter = '(&(objectClass=group)(sAMAccountName=My group*))';
+
+// Return a list of LdapGroupProvider
+$groups = Group::getGroups($client, $filter);
+```
diff --git a/doc/plugin-mail-transports.markdown b/doc/plugin-mail-transports.markdown
index cb7dd6ce..33ce5e3b 100644
--- a/doc/plugin-mail-transports.markdown
+++ b/doc/plugin-mail-transports.markdown
@@ -8,12 +8,12 @@ By default Kanboard supports 3 standards mail transports:
- Sendmail command
With the plugin API you can add a driver for any email provider.
-By example, your plugin can add a mail transport for a provider that uses an HTTP API.
+For example, your plugin can add a mail transport for a provider that uses an HTTP API.
Implementation
--------------
-Your plugin must implements the interface `Kanboard\Core\Mail\ClientInterface` and extends from `Kanboard\Core\Base`.
+Your plugin must implement the interface `Kanboard\Core\Mail\ClientInterface` and extends from `Kanboard\Core\Base`.
The only method you need to implement is `sendEmail()`:
@@ -40,7 +40,7 @@ To register your new mail transport, use the method `setTransport($transport, $c
$this->emailClient->setTransport('myprovider', '\Kanboard\Plugin\MyProvider\MyEmailHandler');
```
-The second argument contains the absolute namespace of your concrete class.
+The second argument contains the absolute name space of your concrete class.
Examples of mail transport plugins
----------------------------------
diff --git a/doc/plugin-metadata.markdown b/doc/plugin-metadata.markdown
index a01b0ddc..1f4bfa2b 100644
--- a/doc/plugin-metadata.markdown
+++ b/doc/plugin-metadata.markdown
@@ -4,10 +4,10 @@ Metadata
You can attach metadata for each project, task and user.
Metadata are custom fields, it's a key/value table.
-By example your plugin can store external information for a task or new settings for a project.
-Basically that allow you to exend the default fields without having to create new tables.
+For example your plugin can store external information for a task or new settings for a project.
+Basically that allow you to extend the default fields without having to create new tables.
-Attach metadata to tasks
+Attach metadata to tasks and remove them
------------------------
```php
@@ -23,6 +23,9 @@ $this->taskMetadata->exists($task_id, 'my_plugin_variable');
// Create or update metadata for the task
$this->taskMetadata->save($task_id, ['my_plugin_variable' => 'something']);
+
+// Remove a metadata from a project
+$this->projectMetadata->remove($project_id, my_plugin_variable);
```
Metadata types
diff --git a/doc/plugin-notifications.markdown b/doc/plugin-notifications.markdown
index 83fdb5e3..15cf4b9b 100644
--- a/doc/plugin-notifications.markdown
+++ b/doc/plugin-notifications.markdown
@@ -17,14 +17,14 @@ $this->userNotificationType->setType('irc', t('IRC'), '\Kanboard\Plugin\IRC\Noti
$this->projectNotificationType->setType('irc', t('IRC'), '\Kanboard\Plugin\IRC\Notification\IrcHandler');
```
-Your handler can be registered for user or project notification. You don't necessary need to support both.
+Your handler can be registered for user or project notification. You don't necessarily need to support both.
When your handler is registered, the end-user can choose to receive the new notification type or not.
Notification Handler
--------------------
-Your notification handler must implements the interface `Kanboard\Notification\NotificationInterface`:
+Your notification handler must implement the interface `Kanboard\Notification\NotificationInterface`:
```php
interface NotificationInterface
diff --git a/doc/plugin-overrides.markdown b/doc/plugin-overrides.markdown
index 905808d5..722b4126 100644
--- a/doc/plugin-overrides.markdown
+++ b/doc/plugin-overrides.markdown
@@ -25,7 +25,7 @@ class Plugin extends Base
Template Overrides
------------------
-Any templates defined in the core can be overrided. By example, you can redefine the default layout or change email notifications.
+Any templates defined in the core can be overridden. For example, you can redefine the default layout or change email notifications.
Example of template override:
diff --git a/doc/plugin-registration.markdown b/doc/plugin-registration.markdown
index 312f61b9..f212fc2e 100644
--- a/doc/plugin-registration.markdown
+++ b/doc/plugin-registration.markdown
@@ -25,7 +25,7 @@ plugins
└── Test <= Unit tests
```
-Only the registration file `Plugin.php` is required. Other folders are optionals.
+Only the registration file `Plugin.php` is required. Other folders are optional.
The first letter of the plugin name must be capitalized.
@@ -43,7 +43,7 @@ namespace Kanboard\Plugin\Foobar;
use Kanboard\Core\Plugin\Base;
-class Plugin extends Plugin\Base
+class Plugin extends Base
{
public function initialize()
{
@@ -52,7 +52,7 @@ class Plugin extends Plugin\Base
}
```
-This file should contains a class `Plugin` defined under the namespace `Kanboard\Plugin\Yourplugin` and extends `Kanboard\Core\Plugin\Base`.
+This file should contain a class `Plugin` defined under the namespace `Kanboard\Plugin\Yourplugin` and extends `Kanboard\Core\Plugin\Base`.
The only required method is `initialize()`. This method is called for each request when the plugin is loaded.
@@ -71,7 +71,7 @@ Available methods from `Kanboard\Core\Plugin\Base`:
- `getPluginHomepage()`: Should return plugin Homepage (link)
- `setContentSecurityPolicy(array $rules)`: Override default HTTP CSP rules
-Your plugin registration class also inherit from `Kanboard\Core\Base`, that means you can access to all classes and methods of Kanboard easily.
+Your plugin registration class can also inherit from Kanboard\Core\Base, that way you can access all classes and methods of Kanboard easily.
This example will fetch the user #123:
@@ -82,20 +82,22 @@ $this->user->getById(123);
Plugin Translations
-------------------
-Plugin can be translated in the same way the rest of the application. You must load the translations yourself when the session is created:
+Plugin can be translated in the same way as the rest of the application. You must load the translations yourself when the session is created:
```php
-$this->on('session.bootstrap', function($container) {
+$this->on('app.bootstrap', function($container) {
Translator::load($container['config']->getCurrentLanguage(), __DIR__.'/Locale');
});
```
-The translations must be stored in `plugins/Myplugin/Locale/xx_XX/translations.php`.
+The translations must be stored in the file `plugins/Myplugin/Locale/xx_XX/translations.php` (replace xx_XX by the language code fr_FR, en_US...).
+
+Translations are stored in a dictionary, if you would like to override an existing string, you just need to use the same key in your translation file.
Dependency Injection Container
------------------------------
-Kanboard use Pimple, a simple PHP Dependency Injection Container. However, Kanboard can register any class in the container easily.
+Kanboard uses Pimple, a simple PHP Dependency Injection Container. However, Kanboard can register any class in the container easily.
Those classes are available everywhere in the application and only one instance is created.
@@ -123,78 +125,4 @@ $this->budget->getDailyBudgetBreakdown(456);
$this->container['hourlyRate']->getAll();
```
-Keys of the containers are unique across the application. If you override an existing class you will change the default behavior.
-
-Event Listening
-----------------
-
-Kanboard use internal events and your plugin can listen and perform actions on these events.
-
-```php
-$this->on('session.bootstrap', function($container) {
- // Do something
-});
-```
-
-- The first argument is the event name
-- The second argument is a PHP callable function (closure or class method)
-
-Extend Automatic Actions
-------------------------
-
-To define a new automatic action with a plugin, you just need to call the method `extendActions()` from the class `Kanboard\Model\Action`, here an example:
-
-```php
-<?php
-
-namespace Kanboard\Plugin\AutomaticAction;
-
-use Kanboard\Core\Plugin\Base;
-
-class Plugin extends Base
-{
- public function initialize()
- {
- $this->action->extendActions(
- '\Kanboard\Plugin\AutomaticAction\Action\DoSomething', // Use absolute namespace
- t('Do something when the task color change')
- );
- }
-}
-```
-
-- The first argument of the method `extendActions()` is the action class with the complete namespace path. **The namespace path must starts with a backslash** otherwise Kanboard will not be able to load your class.
-- The second argument is the description of your automatic action.
-
-The automatic action class must inherits from the class `Kanboard\Action\Base` and implements all abstract methods:
-
-- `getCompatibleEvents()`
-- `getActionRequiredParameters()`
-- `getEventRequiredParameters()`
-- `doAction(array $data)`
-- `hasRequiredCondition(array $data)`
-
-For more details you should take a look to existing automatic actions or this [plugin example](https://github.com/kanboard/plugin-example-automatic-action).
-
-Extend ACL
-----------
-
-Kanboard use an access list for privilege separations. Your extension can add new rules:
-
-```php
-$this->acl->extend('project_manager_acl', array('mycontroller' => '*'));
-```
-
-- The first argument is the ACL name
-- The second argument are the new rules
- + Syntax to include only some actions: `array('controller' => array('action1', 'action2'))`
- + Syntax to include all actions of a controller: `array('controller' => '*')`
- + Everything is lowercase
-
-List of ACL:
-
-- `public_acl`: Public access without authentication
-- `project_member_acl`: Project member access
-- `project_manager_acl`: Project manager access
-- `project_admin_acl`: Project Admins
-- `admin_acl`: Administrators
+Keys of the containers are unique across the application. If you override an existing class, you will change the default behavior.
diff --git a/doc/plugin-routes.markdown b/doc/plugin-routes.markdown
new file mode 100644
index 00000000..b943bb19
--- /dev/null
+++ b/doc/plugin-routes.markdown
@@ -0,0 +1,85 @@
+Custom Routes
+=============
+
+When URL rewriting is enabled, you can define custom routes from your plugins.
+
+Define new routes
+-----------------
+
+Routes are handled by the class `Kanboard\Core\Http\Route`.
+
+New routes can be added by using the method `addRoute($path, $controller, $action, $plugin)`, here an example:
+
+```php
+$this->route->addRoute('/my/custom/route', 'myController', 'myAction', 'myplugin');
+```
+
+When the end-user go to the URL `/my/custom/route`, the method `Kanboard\Plugin\Myplugin\Controller\MyController::myAction()` will be executed.
+
+The first character of the controller and the plugin name will converted in uppercase with the function `ucfirst()`.
+
+You can also define routes with variables:
+
+```php
+$this->route->addRoute('/my/route/:my_variable', 'myController', 'myAction', 'myplugin');
+```
+
+The colon prefix `:`, define a variable.
+For example `:my_variable` declare a new variable named `my_variable`.
+
+To fetch the value of the variable you can use the method `getStringParam()` or `getIntegerParam()` from the class `Kanboard\Core\Http\Request`:
+
+If we have the URL `/my/route/foobar`, the value of `my_variable` is `foobar`:
+
+```php
+$this->request->getStringParam('my_variable'); // Return foobar
+```
+
+Generate links based on the routing table
+-----------------------------------------
+
+From templates, you have to use the helper `Kanboard\Helper\Url`.
+
+### Generate a HTML link
+
+```php
+<?= $this->url->link('My link', 'mycontroller', 'myaction', array('plugin' => 'myplugin')) ?>
+```
+
+Will generate this HTML:
+
+```html
+<a href="/my/custom/route">My link</a>
+```
+
+### Generate only the attribute `href`:
+
+```php
+<?= $this->url->href('My link', 'mycontroller', 'myaction', array('plugin' => 'myplugin')) ?>
+```
+
+HTML output:
+
+```html
+/my/custom/route
+```
+
+HTML output when URL rewriting is not enabled:
+
+```html
+?controller=mycontroller&amp;action=myaction&amp;plugin=myplugin
+```
+
+### Generate redirect link:
+
+From a controller, if you need to perform a redirection:
+
+```php
+$this->url->to('mycontroller', 'myaction', array('plugin' => 'myplugin'));
+```
+
+Generate:
+
+```
+?controller=mycontroller&action=myaction&plugin=myplugin
+```
diff --git a/doc/plugin-schema-migrations.markdown b/doc/plugin-schema-migrations.markdown
index d595605f..36a57f37 100644
--- a/doc/plugin-schema-migrations.markdown
+++ b/doc/plugin-schema-migrations.markdown
@@ -1,7 +1,8 @@
Plugin Schema Migrations
========================
-Kanboard execute database migrations automatically for you. Migrations must be stored in a folder **Schema** and the filename must be the same as the database driver:
+Kanboard executes database migrations automatically for you.
+Migrations must be stored in a folder **Schema** and the filename must be the same as the database driver:
```bash
Schema
@@ -31,7 +32,7 @@ function version_1($pdo)
```
- The constant `VERSION` is the last version of your schema
-- Each function is a migration `version_1()`, `version_2()`, etc...
+- Each function is a migration `version_1()`, `version_2()`, etc.
- A `PDO` instance is passed as first argument
- Everything is executed inside a transaction, if something doesn't work a rollback is performed and the error is displayed to the user
diff --git a/doc/plugins.markdown b/doc/plugins.markdown
index 0a08fa8e..55575612 100644
--- a/doc/plugins.markdown
+++ b/doc/plugins.markdown
@@ -5,19 +5,30 @@ Note: The plugin API is **considered alpha** at the moment.
Plugins are useful to extend the core functionalities of Kanboard, adding features, creating themes or changing the default behavior.
-Plugin creators should specify explicitly the compatible versions of Kanboard. Internal code of Kanboard may change over the time and your plugin must be tested with new versions.
+Plugin creators should specify explicitly the compatible versions of Kanboard. Internal code of Kanboard may change over time and your plugin must be tested with new versions. Always check the [ChangeLog](https://github.com/fguillot/kanboard/blob/master/ChangeLog) for breaking changes.
- [Creating your plugin](plugin-registration.markdown)
- [Using plugin hooks](plugin-hooks.markdown)
+- [Events](plugin-events.markdown)
- [Override default application behaviors](plugin-overrides.markdown)
- [Add schema migrations for plugins](plugin-schema-migrations.markdown)
+- [Custom routes](plugin-routes.markdown)
- [Add mail transports](plugin-mail-transports.markdown)
- [Add notification types](plugin-notifications.markdown)
+- [Add automatic actions](plugin-automatic-actions.markdown)
- [Attach metadata to users, tasks and projects](plugin-metadata.markdown)
+- [Authentication architecture](plugin-authentication-architecture.markdown)
+- [Authentication plugin registration](plugin-authentication.markdown)
+- [Authorization Architecture](plugin-authorization-architecture.markdown)
+- [Custom Group Providers](plugin-group-provider.markdown)
+- [External Link Providers](plugin-external-link.markdown)
+- [LDAP client](plugin-ldap-client.markdown)
Examples of plugins
-------------------
+- [SMS Two-Factor Authentication](https://github.com/kanboard/plugin-sms-2fa)
+- [Reverse-Proxy Authentication with LDAP support](https://github.com/kanboard/plugin-reverse-proxy-ldap)
- [Slack](https://github.com/kanboard/plugin-slack)
- [Hipchat](https://github.com/kanboard/plugin-hipchat)
- [Jabber](https://github.com/kanboard/plugin-jabber)
@@ -26,7 +37,7 @@ Examples of plugins
- [Postmark](https://github.com/kanboard/plugin-postmark)
- [Amazon S3](https://github.com/kanboard/plugin-s3)
- [Budget planning](https://github.com/kanboard/plugin-budget)
-- [User timetable](https://github.com/kanboard/plugin-timetable)
+- [User timetables](https://github.com/kanboard/plugin-timetable)
- [Subtask Forecast](https://github.com/kanboard/plugin-subtask-forecast)
- [Automatic Action example](https://github.com/kanboard/plugin-example-automatic-action)
- [Theme plugin example](https://github.com/kanboard/plugin-example-theme)
diff --git a/doc/postgresql-configuration.markdown b/doc/postgresql-configuration.markdown
index 904dfc0f..627e2ded 100644
--- a/doc/postgresql-configuration.markdown
+++ b/doc/postgresql-configuration.markdown
@@ -6,7 +6,7 @@ By default, Kanboard use Sqlite to store its data but it's also possible to use
Requirements
------------
-- A Postgresql server already installed and configured
+- Postgresql server already installed and configured
- The PHP extension `pdo_pgsql` installed (Debian/Ubuntu: `apt-get install php5-pgsql`)
Note: Kanboard is tested with **Postgresql 9.3 and 9.4**
@@ -22,7 +22,7 @@ CREATE DATABASE kanboard;
### Create a config file
-The file `config.php` should contains those values:
+The file `config.php` should contain those values:
```php
<?php
@@ -41,7 +41,7 @@ Note: You can also rename the template file `config.default.php` to `config.php`
### Importing SQL dump (alternative method)
-The first time, Kanboard will run one by one each database migration and this process can take some time according to your configuration.
+For the first time, Kanboard will run one by one each database migration and this process can take some time according to your configuration.
To avoid any issues or potential timeouts you can initialize the database directly by importing the SQL schema:
@@ -49,4 +49,4 @@ To avoid any issues or potential timeouts you can initialize the database direct
psql -U postgres my_database < app/Schema/Sql/postgres.sql
```
-The file `app/Schema/Sql/postgres.sql` is a sql dump that represent the last version of the database.
+The file `app/Schema/Sql/postgres.sql` is a sql dump that represents the last version of the database.
diff --git a/doc/project-configuration.markdown b/doc/project-configuration.markdown
index 252ace67..e458ebe3 100644
--- a/doc/project-configuration.markdown
+++ b/doc/project-configuration.markdown
@@ -1,4 +1,4 @@
-Project settings
+Project Settings
================
Go to the menu **Settings**, then choose **Project settings** on the left.
@@ -38,5 +38,5 @@ If another subtask have the status "in progress", the user will see this dialog
- If enabled, closed tasks will be included in the cumulative flow diagram.
- If disabled, only open tasks will be included.
-- This option affect the column "total" of the table "project_daily_column_stats"
+- This option affects the column "total" of the table "project_daily_column_stats"
diff --git a/doc/project-permissions.markdown b/doc/project-permissions.markdown
index 762ab307..16586bc6 100644
--- a/doc/project-permissions.markdown
+++ b/doc/project-permissions.markdown
@@ -1,49 +1,23 @@
-Project permissions
+Project Permissions
===================
-A project can have two kinds of people: **project managers** and **project members**.
+Each project is isolated and compartmented from each other.
+The project access must be allowed by the project owner.
-- Project managers can manage the configuration of the project and access to the reports.
-- Project members can only do basic operations (create or move tasks).
+Each user and each group can have a different role assigned.
+There are 3 types of [roles for projects](roles.markdown):
-When you create a new project, you are automatically assigned as a project manager.
+- Project Manager
+- Project Member
+- Project Viewer
-Kanboard administrators can access to everything but they are not necessary project members or managers. **Those permissions are defined at the project level**.
+Only administrators have access to everything.
-Permissions for each role
--------------------------
+Roles assignation is available from **Project Settings > Permissions**:
-### Project members
+![Project Permissions](screenshots/project-permissions.png)
-- Use the board (create, move and edit tasks)
-- Remove only tasks created by themselves
+If you choose to allow everybody, all Kanboard users will be considered Project Member.
+That also means there is no role management anymore. Permission per user or per group cannot be applied.
-### Project managers
-
-- Use the board
-- Configure the project
- - Share, rename, duplicate and disable the project
- - Manage swimlanes, categories, columns and users
- - Edit automatic actions
-- CSV Exports
-- Remove tasks of any project members
-- Access to the analytics section
-
-They **cannot remove the project**.
-
-Manage users and permissions
-----------------------------
-
-To define project roles, go to the **project configuration page** then click on **User management**.
-
-### User management
-
-![Project permissions](http://kanboard.net/screenshots/documentation/project-permissions.png)
-
-From there, you can choose to add new members, change the role or revoke user access.
-
-### Allow everybody
-
-If you choose to allow everybody (all Kanboard users), the project is considered public.
-
-That means there is no role management anymore. Permissions per user cannot be applied.
+Private projects cannot define permission.
diff --git a/doc/project-types.markdown b/doc/project-types.markdown
new file mode 100644
index 00000000..ae18b9ef
--- /dev/null
+++ b/doc/project-types.markdown
@@ -0,0 +1,14 @@
+Project Types
+=============
+
+There are two kinds of projects:
+
+| Type | Description |
+|-------------------|-----------------------------------------------------------------------|
+| Team Project | Project with user and group management |
+| Private Project | Project that belongs to only one person, there is no user management |
+
+- Only Administrators and Application Managers can create team projects.
+- Private projects can be created by anyone.
+
+[Read the documentation about roles in Kanboard](roles.markdown)
diff --git a/doc/project-views.markdown b/doc/project-views.markdown
index e1fb4c1e..b45f8178 100644
--- a/doc/project-views.markdown
+++ b/doc/project-views.markdown
@@ -1,9 +1,11 @@
-Board, Calendar and List views
+Board, Calendar and List Views
==============================
-For each project, tasks can be visualized with several views: **Board, Calendar, List and Gantt**. Each view show the result of the filter box at the top. The search engine use the [advanced syntax](search.markdown).
+For each project, tasks can be visualized with several views: **Board, Calendar, List and Gantt**.
+Each view shows the result of the filter box at the top.
+The search engine uses the [advanced syntax](search.markdown).
-Board view
+Board View
----------
![Board view](http://kanboard.net/screenshots/documentation/board-view.png)
@@ -17,8 +19,8 @@ Board view
When the task limit is reached for a column, the background becomes red. That means there are too many tasks in progress at the same time.
[Learn more about board configuration](board-configuration.markdown)
-
-Calendar view
+
+Calendar View
--------------
![Calendar view](http://kanboard.net/screenshots/documentation/calendar-view.png)
@@ -28,19 +30,19 @@ Calendar view
- You can also use the keyboard shortcut **"v c"** to switch to the calendar view.
- [Learn more about calendar configuration](calendar-configuration.markdown)
-List view
-----------
+List View
+---------
![List view](http://kanboard.net/screenshots/documentation/list-view.png)
- With this view all results of your search are displayed in a table.
- You can also use the keyboard shortcut **"v l"** to switch to the list view.
-Gantt view
+Gantt View
----------
![Gantt view](http://kanboard.net/screenshots/documentation/gantt-view.png)
-- The Gantt view display tasks on a horizontal timeline
+- The Gantt view displays tasks on a horizontal timeline
- The start date and the due date are used to display the chart
-- For a quick access, use the keyboard shortcut: **v g**
+- For quick access, use the keyboard shortcut: **v g**
diff --git a/doc/recommended-configuration.markdown b/doc/recommended-configuration.markdown
deleted file mode 100644
index 35ed652d..00000000
--- a/doc/recommended-configuration.markdown
+++ /dev/null
@@ -1,36 +0,0 @@
-Recommended Configuration
-=========================
-
-Server side
------------
-
-- Modern Linux/Unix operating system: **Ubuntu/Debian or FreeBSD**
-- Most recent version of PHP and Apache (Kanboard is compatible with PHP 5.3, 5.4, 5.5, 5.6 and 7.0)
-- Use the Sqlite database only when you have a disk with fast I/O (SSD disks) otherwise use Mysql or Postgresql
-
-Client side
------------
-
-- Use a modern browser: **Mozilla Firefox or Google Chrome or Safari**
-
-Tested configurations
----------------------
-
-The following configurations are tested with Kanboard but that doesn't mean all features are available:
-
-### Server
-
-- Ubuntu 14.04 LTS
-- Debian 6, 7 and 8
-- Centos 6.x and 7.0
-- Windows 2012 Server
-- Windows 2008 Server
-
-### Desktops
-
-- Last version of Mozilla Firefox, Safari and Google Chrome
-- Microsoft Internet Explorer 11
-
-### Tablets
-
-- iPad mini 3
diff --git a/doc/recurring-tasks.markdown b/doc/recurring-tasks.markdown
index b1de8d05..d7283a54 100644
--- a/doc/recurring-tasks.markdown
+++ b/doc/recurring-tasks.markdown
@@ -3,14 +3,14 @@ Recurring tasks
To fit with the Kanban methodology, the recurring tasks are not based on a date but on board events.
-- Recurring tasks are duplicated to the first column of the board when the selected events occurs
+- Recurring tasks are duplicated to the first column of the board when the selected events occur
- The due date can be recalculated automatically
- Each task records the task id of the parent task that created it and the child task created
Configuration
-------------
-Go to the task view page or use the dropdown menu on the board, then select **Edit recurrence**.
+Go to the task view page or use the drop-down menu on the board, then select **Edit recurrence**.
![Recurring task](http://kanboard.net/screenshots/documentation/recurring-tasks.png)
diff --git a/doc/requirements.markdown b/doc/requirements.markdown
new file mode 100644
index 00000000..cf2c7c8a
--- /dev/null
+++ b/doc/requirements.markdown
@@ -0,0 +1,103 @@
+Requirements
+============
+
+Server side
+-----------
+
+### Compatible Operating Systems
+
+| Operating System |
+|--------------------------------|
+| Linux Ubuntu Trusty 14.04 LTS |
+| Linux Centos 6.x |
+| Linux Centos 7.x |
+| Linux Redhat 6.x |
+| Linux Redhat 7.x |
+| Linux Debian 8 |
+| FreeBSD 10.x |
+| Microsoft Windows 2012 R2 |
+| Microsoft Windows 2008 |
+
+### Compatible Databases
+
+| Database |
+|--------------------|
+| Sqlite 3.x |
+| Mysql >= 5.5 |
+| MariaDB >= 10 |
+| Postgresql >= 9.3 |
+
+Which database to choose?
+
+| Type | Usage |
+|-----------------|-----------------------------------------------------|
+| Sqlite | Single user or small team (almost no concurrency) |
+| Mysql/Postgres | Larger team, high-availability configuration |
+
+Do not use Sqlite on NFS mounts, use Sqlite only when you have a disk with fast I/O
+
+### Compatible Web Servers
+
+| Web Server |
+|--------------------|
+| Apache HTTP Server |
+| Nginx |
+| Microsoft IIS |
+
+Kanboard is pre-configured to work with Apache (URL rewriting).
+
+### PHP Versions
+
+| PHP Version |
+|----------------|
+| PHP >= 5.3.3 |
+| PHP 5.4 |
+| PHP 5.5 |
+| PHP 5.6 |
+| PHP 7.x |
+
+### PHP Extensions Required
+
+| PHP Extensions required | Note |
+|----------------------------|-------------------------------|
+| pdo_sqlite | Only if you use Sqlite |
+| pdo_mysql | Only if you use Mysql/MariaDB |
+| pdo_pgsql | Only if you use Postgres |
+| gd | |
+| mbstring | |
+| openssl | |
+| json | |
+| hash | |
+| ctype | |
+| session | |
+| ldap | Only for LDAP authentication |
+| Zend OPcache | Recommended |
+
+### Recommendations
+
+- Modern Linux or Unix operating system.
+- Best performances are obtained with the latest version of PHP with OPcode caching activated.
+
+Client side
+-----------
+
+### Browsers
+
+Always use a modern browser with the latest version if possible:
+
+| Browser |
+|---------------------------------------|
+| Safari |
+| Google Chrome |
+| Mozilla Firefox |
+| Microsoft Internet Explorer >= 11 |
+| Microsoft Edge |
+
+### Devices
+
+| Device | Screen resolution |
+|-------------------|--------------------|
+| Laptop or desktop | >= 1366 x 768 |
+| Tablet | >= 1024 x 768 |
+
+Kanboard is not yet optimized for smartphones. It's working but the user interface is not really convenient to use.
diff --git a/doc/reverse-proxy-authentication.markdown b/doc/reverse-proxy-authentication.markdown
index 7c001f3d..609bad7a 100644
--- a/doc/reverse-proxy-authentication.markdown
+++ b/doc/reverse-proxy-authentication.markdown
@@ -8,18 +8,18 @@ The authentication is done by another system, Kanboard doesn't know your passwor
Requirements
------------
-- A well configured reverse proxy
+- A well-configured reverse proxy
or
-- Apache auth on the same server
+- Apache Auth on the same server
How does this work?
-------------------
1. Your reverse proxy authenticates the user and send the username through a HTTP header.
-2. Kanboard retreive the username from the request
+2. Kanboard retrieve the username from the request
- The user is created automatically if necessary
- Open a new Kanboard session without any prompt assuming it's valid
@@ -29,7 +29,7 @@ Installation instructions
### Setting up your reverse proxy
This is not in the scope of this documentation.
-You should check the user login is sent by the reverse proxy using a HTTP header, and find which one.
+You should check the user login is sent by the reverse proxy using a HTTP header, and find out which one.
### Setting up Kanboard
@@ -59,6 +59,6 @@ Notes:
- If the proxy is the same web server that runs Kanboard, according the [CGI protocol](http://www.ietf.org/rfc/rfc3875) the header name will be `REMOTE_USER`. By example, Apache add `REMOTE_USER` by default if `Require valid-user` is set.
-- If Apache is a reverse proxy to another Apache running Kanboard, the header `REMOTE_USER` is not set (same behaviour with IIS and Nginx).
+- If Apache is a reverse proxy to another Apache running Kanboard, the header `REMOTE_USER` is not set (same behavior with IIS and Nginx).
-- If you have a real reverse proxy, the [HTTP ICAP draft](http://tools.ietf.org/html/draft-stecher-icap-subid-00#section-3.4) proposes the header to be `X-Authenticated-User`. This de-facto standart has been adopted by a number of tools.
+- If you have a real reverse proxy, the [HTTP ICAP draft](http://tools.ietf.org/html/draft-stecher-icap-subid-00#section-3.4) proposes the header to be `X-Authenticated-User`. This de facto standard has been adopted by a number of tools.
diff --git a/doc/roles.markdown b/doc/roles.markdown
new file mode 100644
index 00000000..1b65a8c4
--- /dev/null
+++ b/doc/roles.markdown
@@ -0,0 +1,25 @@
+User Roles
+==========
+
+Application Roles
+-----------------
+
+Each Kanboard user has one of these roles:
+
+| Role | Description |
+|---------------|-----------------------------------------------------------------|
+| Administrator | Access to everything |
+| Manager | Can create team projects but cannot change application settings |
+| User | Can create private projects only |
+
+Project Roles
+-------------
+
+Each individual team project can assign a different role to each user and group:
+
+| Role | Description |
+|-----------------|----------------------------------------------------------------------|
+| Project Manager | Can change project settings, access to the Gantt chart and reports |
+| Project Member | Can create tasks and use the board |
+| Project Viewer | Read-only access to the board and tasks |
+
diff --git a/doc/rss.markdown b/doc/rss.markdown
index 57c81020..7930ec40 100644
--- a/doc/rss.markdown
+++ b/doc/rss.markdown
@@ -4,7 +4,7 @@ RSS/Atom subscriptions
Kanboard supports RSS feeds for projects and users.
- Project feeds contains only the activity the project
-- User feeds contains the activity stream of all projects the user is member
+- User feeds contains the activity stream of all projects the user is a member
Those subscriptions are only activated when the public access is enabled in the user profile or in the project settings.
@@ -20,4 +20,4 @@ Enable/disable user RSS feeds
Go to **User profile > Public access**.
-The RSS link is protected by a random token, only people who knows the url can access to the feed. \ No newline at end of file
+The RSS link is protected by a random token, only people who know the URL can access to the feed. \ No newline at end of file
diff --git a/doc/screenshots.markdown b/doc/screenshots.markdown
index 95972405..30a3c532 100644
--- a/doc/screenshots.markdown
+++ b/doc/screenshots.markdown
@@ -4,11 +4,11 @@ Adding screenshots
You can copy and paste images directly in Kanboard to save time.
These images are uploaded as attachments to the task.
-This is especially useful for taking screenshots to describe an issue by example.
+This is especially useful for taking screenshots to describe an issue for example.
You can add screenshots directly from the board by clicking on the dropdown menu or in the task view page.
-![Dropdown screenshot menu](http://kanboard.net/screenshots/documentation/dropdown-screenshot.png)
+![Drop-down screenshot menu](http://kanboard.net/screenshots/documentation/dropdown-screenshot.png)
To add a new image, take your screenshot and paste with CTRL+V or Command+V:
@@ -17,7 +17,7 @@ To add a new image, take your screenshot and paste with CTRL+V or Command+V:
On Mac OS X, you can use those shortcuts to take screenshots:
- Command-Control-Shift-3: Take a screenshot of the screen, and save it to the clipboard
-- Command-Control-Shift-4, then select an area: Take a screenshot of an area and save it to the clipboard
+- Command-Control-Shift-4, then select an area: Take a screenshot of the area and save it to the clipboard
- Command-Control-Shift-4, then space, then click a window: Take a screenshot of a window and save it to the clipboard
There are also several third-party applications that can be used to take screenshots with annotations and shapes.
diff --git a/doc/screenshots/groups-management.png b/doc/screenshots/groups-management.png
new file mode 100644
index 00000000..475b6827
--- /dev/null
+++ b/doc/screenshots/groups-management.png
Binary files differ
diff --git a/doc/screenshots/mention-autocomplete.png b/doc/screenshots/mention-autocomplete.png
new file mode 100644
index 00000000..f23fb6d1
--- /dev/null
+++ b/doc/screenshots/mention-autocomplete.png
Binary files differ
diff --git a/doc/screenshots/project-permissions.png b/doc/screenshots/project-permissions.png
new file mode 100644
index 00000000..c4450755
--- /dev/null
+++ b/doc/screenshots/project-permissions.png
Binary files differ
diff --git a/doc/search.markdown b/doc/search.markdown
index 34a20bc6..1a97a7fc 100644
--- a/doc/search.markdown
+++ b/doc/search.markdown
@@ -1,12 +1,12 @@
Advanced Search Syntax
======================
-Kanboard use a simple query language for advanced search.
+Kanboard uses a simple query language for advanced search.
Example of query
----------------
-This example will returns all tasks assigned to me with a due date for tomorrow and a title that contains "my title":
+This example will return all tasks assigned to me with a due date for tomorrow and a title that contains "my title":
```
assigne:me due:tomorrow my title
@@ -17,7 +17,7 @@ Search by task id or title
- Search by task id: `#123`
- Search by task id and task title: `123`
-- Search by task title: anything that don't match any search attributes
+- Search by task title: anything that doesn't match any search attributes
Search by status
----------------
@@ -27,8 +27,8 @@ Attribute: **status**
- Query to find open tasks: `status:open`
- Query to find closed tasks: `status:closed`
-Search by assignee
-------------------
+Search by assignees
+-------------------
Attribute: **assignee**
@@ -48,8 +48,8 @@ Attribute: **color**
- Query to search by color id: `color:blue`
- Query to search by color name: `color:"Deep Orange"`
-Search by due date
-------------------
+Search by the due date
+----------------------
Attribute: **due**
@@ -58,9 +58,9 @@ Attribute: **due**
- Search tasks due yesterday: `due:yesterday`
- Search tasks due with the exact date: `due:2015-06-29`
-The date must use the ISO8601 format: **YYYY-MM-DD**.
+The date must use the ISO 8601 format: **YYYY-MM-DD**.
-All string formats supported by the `strtotime()` function are supported, by example `next Thursday`, `-2 days`, `+2 months`, `tomorrow`, etc...
+All string formats supported by the `strtotime()` function are supported, for example `next Thursday`, `-2 days`, `+2 months`, `tomorrow`, etc.
Operators supported with a date:
@@ -74,11 +74,11 @@ Search by modification date
Attribute: **modified** or **updated**
-The date formats are the same as the due date.
+The date formats are the same as the due date.
There is also a filter by recently modified tasks: `modified:recently`.
-This query will use the same value as the board highlight period configured in settings.
+This query will use the same value as the board highlight period configured in settings.
Search by creation date
-----------------------
@@ -119,20 +119,28 @@ Attribute: **project**
- Find tasks by project id: `project:23`
- Find tasks for several projects: `project:"My project A" project:"My project B"`
-Search by column
-----------------
+Search by columns
+-----------------
Attribute: **column**
- Find tasks by column name: `column:"Work in progress"`
- Find tasks for several columns: `column:"Backlog" column:ready`
-Search by swimlane
-------------------
+Search by swim lane
+-------------------
Attribute: **swimlane**
-- Find tasks by swimlane: `swimlane:"Version 42"`
-- Find tasks in the default swimlane: `swimlane:default`
-- Find tasks into several swimlanes: `swimlane:"Version 1.2" swimlane:"Version 1.3"`
+- Find tasks by swim lane: `swimlane:"Version 42"`
+- Find tasks in the default swim lane: `swimlane:default`
+- Find tasks into several swim lanes: `swimlane:"Version 1.2" swimlane:"Version 1.3"`
+
+Search by task link
+------------------
+
+Attribute: **link**
+
+- Find tasks by link name: `link:"is a milestone of"`
+- Find tasks into several links: `link:"is a milestone of" link:"relates to"`
diff --git a/doc/sharing-projects.markdown b/doc/sharing-projects.markdown
index 17a552ce..c10b0f39 100644
--- a/doc/sharing-projects.markdown
+++ b/doc/sharing-projects.markdown
@@ -4,7 +4,7 @@ Sharing boards and tasks
By default, boards are private but it's possible to make a board public.
A public board **cannot be modified, it's a read-only access**.
-This access is protected by a random token, only people who have the right url can see the board.
+This access is protected by a random token, only people who have the right URL can see the board.
Public boards are automatically refreshed every 60 seconds.
Task details are also available in read-only.
diff --git a/doc/subtasks.markdown b/doc/subtasks.markdown
index e1acd98c..2819aaf4 100644
--- a/doc/subtasks.markdown
+++ b/doc/subtasks.markdown
@@ -17,7 +17,7 @@ From the task view, on left sidebar click on **Add a subtask**:
![Add a subtask](http://kanboard.net/screenshots/documentation/add-subtask.png)
-You can also add quickly a subtask by entering only the title:
+You can also add a subtask quickly by entering only the title:
![Add a subtask from the task view](http://kanboard.net/screenshots/documentation/add-subtask-shortcut.png)
@@ -40,5 +40,5 @@ Subtask timer
- Each time a subtask is in progress, the timer is also started. The timer can be started and stopped at any time.
- The timer records the time spent on the subtask automatically. You can also change manually the value of the time spent field when you edit a subtask.
- The time calculated is rounded to the nearest quarter. This information is recorded in a separate table.
-- The task time spent and time estimated are updated automatically according to the sum of all subtasks.
+- The task time spent and time estimated is updated automatically according to the sum of all subtasks.
diff --git a/doc/suse-installation.markdown b/doc/suse-installation.markdown
new file mode 100644
index 00000000..ce36c8f7
--- /dev/null
+++ b/doc/suse-installation.markdown
@@ -0,0 +1,14 @@
+Installation on OpenSuse
+========================
+
+OpenSuse Leap 42.1
+------------------
+
+```bash
+sudo zypper install php5 php5-sqlite php5-gd php5-json php5-mcrypt php5-mbstring php5-openssl
+cd /srv/www/htdocs
+sudo wget http://kanboard.net/kanboard-latest.zip
+sudo unzip kanboard-latest.zip
+sudo chmod -R 777 kanboard
+sudo rm kanboard-latest.zip
+```
diff --git a/doc/swimlanes.markdown b/doc/swimlanes.markdown
index 25e8b6b9..3a67fb89 100644
--- a/doc/swimlanes.markdown
+++ b/doc/swimlanes.markdown
@@ -1,8 +1,8 @@
-Swimlanes
-=========
+Swim lanes
+==========
Swimlanes are horizontal separations in your board.
-By example, it's useful to separate software releases, divide your tasks in different products, teams or what ever you want.
+By example, it's useful to separate software releases, divide your tasks in different products, teams or whatever you want.
Board with swimlanes
--------------------
diff --git a/doc/task-links.markdown b/doc/task-links.markdown
index 1eab51fd..e9ffb014 100644
--- a/doc/task-links.markdown
+++ b/doc/task-links.markdown
@@ -1,7 +1,7 @@
Task Links
==========
-Tasks can be linked together with predefined relationships:
+Tasks can be linked together with pre-defined relationships:
![Task Links](http://kanboard.net/screenshots/documentation/task-links.png)
diff --git a/doc/tests.markdown b/doc/tests.markdown
index 31937f33..b2d95491 100644
--- a/doc/tests.markdown
+++ b/doc/tests.markdown
@@ -1,7 +1,7 @@
-How to run units and functionals tests?
-=======================================
+Automated tests
+===============
-[PHPUnit](https://phpunit.de/) is used to run automatic tests on Kanboard.
+[PHPUnit](https://phpunit.de/) is used to run automated tests on Kanboard.
You can run tests across different databases (Sqlite, Mysql and Postgresql) to be sure that the result is the same everywhere.
@@ -9,31 +9,18 @@ Requirements
------------
- Linux/Unix machine
-- PHP command line
+- PHP cli
- PHPUnit installed
- Mysql and Postgresql (optional)
-Install the latest version of PHPUnit
--------------------------------------
-
-Simply download the PHPUnit PHAR et copy the file somewhere in your `$PATH`:
-
-```bash
-wget https://phar.phpunit.de/phpunit.phar
-chmod +x phpunit.phar
-sudo mv phpunit.phar /usr/local/bin/phpunit
-phpunit --version
-PHPUnit 4.2.6 by Sebastian Bergmann.
-```
-
-Running unit tests
-------------------
+Unit Tests
+----------
-### Testing with Sqlite
+### Test with Sqlite
-Sqlite tests use a in-memory database, nothing is written on the filesystem.
+Sqlite tests use a in-memory database, nothing is written on the file system.
-The config file is `tests/units.sqlite.xml`.
+The PHPUnit config file is `tests/units.sqlite.xml`.
From your Kanboard directory, run the command `phpunit -c tests/units.sqlite.xml`.
Example:
@@ -41,21 +28,26 @@ Example:
```bash
phpunit -c tests/units.sqlite.xml
-PHPUnit 4.2.6 by Sebastian Bergmann.
+PHPUnit 5.0.0 by Sebastian Bergmann and contributors.
-Configuration read from /Volumes/Devel/apps/kanboard/tests/units.sqlite.xml
+............................................................... 63 / 649 ( 9%)
+............................................................... 126 / 649 ( 19%)
+............................................................... 189 / 649 ( 29%)
+............................................................... 252 / 649 ( 38%)
+............................................................... 315 / 649 ( 48%)
+............................................................... 378 / 649 ( 58%)
+............................................................... 441 / 649 ( 67%)
+............................................................... 504 / 649 ( 77%)
+............................................................... 567 / 649 ( 87%)
+............................................................... 630 / 649 ( 97%)
+................... 649 / 649 (100%)
-................................................................. 65 / 74 ( 87%)
-.........
+Time: 1.22 minutes, Memory: 151.25Mb
-Time: 9.05 seconds, Memory: 17.75Mb
-
-OK (74 tests, 6145 assertions)
+OK (649 tests, 43595 assertions)
```
-**NOTE:** PHPUnit is already included in the Vagrant environment
-
-### Testing with Mysql
+### Test with Mysql
You must have Mysql or MariaDb installed on localhost.
@@ -68,27 +60,10 @@ By default, those credentials are used:
For each execution the database is dropped and created again.
-The config file is `tests/units.mysql.xml`.
+The PHPUnit config file is `tests/units.mysql.xml`.
From your Kanboard directory, run the command `phpunit -c tests/units.mysql.xml`.
-Example:
-
-```bash
-phpunit -c tests/units.mysql.xml
-
-PHPUnit 4.2.6 by Sebastian Bergmann.
-
-Configuration read from /Volumes/Devel/apps/kanboard/tests/units.mysql.xml
-
-................................................................. 65 / 74 ( 87%)
-.........
-
-Time: 49.77 seconds, Memory: 17.50Mb
-
-OK (74 tests, 6145 assertions)
-```
-
-### Testing with Postgresql
+### Test with Postgresql
You must have Postgresql installed on localhost.
@@ -100,37 +75,20 @@ By default, those credentials are used:
- Database: **kanboard_unit_test**
Be sure to allow the user `postgres` to create and drop databases.
-For each execution the database is dropped and created again.
+The database is recreated for each execution.
-The config file is `tests/units.postgres.xml`.
+The PHPUnit config file is `tests/units.postgres.xml`.
From your Kanboard directory, run the command `phpunit -c tests/units.postgres.xml`.
-Example:
-
-```bash
-phpunit -c tests/units.postgres.xml
-
-PHPUnit 4.2.6 by Sebastian Bergmann.
-
-Configuration read from /Volumes/Devel/apps/kanboard/tests/units.postgres.xml
-
-................................................................. 65 / 74 ( 87%)
-.........
-
-Time: 52.66 seconds, Memory: 17.50Mb
-
-OK (74 tests, 6145 assertions)
-```
-
-Running functionals tests
--------------------------
+Integration Tests
+-----------------
Actually only the API calls are tested.
Real HTTP calls are made with those tests.
-So a local instance of Kanboard is necessary and must listen on `http://localhost:8000`.
+So a local instance of Kanboard is necessary and must listen on `http://localhost:8000/`.
-Don't forget that all data will be removed/altered by the test suite.
+All data will be removed/altered by the test suite.
Moreover the script will reset and set a new API key.
1. Start a local instance of Kanboard `php -S 127.0.0.1:8000`
@@ -138,27 +96,27 @@ Moreover the script will reset and set a new API key.
The same method as above is used to run tests across different databases:
-- Sqlite: `phpunit -c tests/functionals.sqlite.xml`
-- Mysql: `phpunit -c tests/functionals.mysql.xml`
-- Postgresql: `phpunit -c tests/functionals.postgres.xml`
+- Sqlite: `phpunit -c tests/integration.sqlite.xml`
+- Mysql: `phpunit -c tests/integration.mysql.xml`
+- Postgresql: `phpunit -c tests/integration.postgres.xml`
Example:
```bash
-phpunit -c tests/functionals.sqlite.xml
-
-PHPUnit 4.2.6 by Sebastian Bergmann.
+phpunit -c tests/integration.sqlite.xml
-Configuration read from /Volumes/Devel/apps/kanboard/tests/functionals.sqlite.xml
+PHPUnit 5.0.0 by Sebastian Bergmann and contributors.
-..........................................
+............................................................... 63 / 135 ( 46%)
+............................................................... 126 / 135 ( 93%)
+......... 135 / 135 (100%)
-Time: 1.72 seconds, Memory: 4.25Mb
+Time: 1.18 minutes, Memory: 14.75Mb
-OK (42 tests, 160 assertions)
+OK (135 tests, 526 assertions)
```
-Continuous Integration with Travis-ci
+Continuous Integration with Travis-CI
-------------------------------------
After each commit pushed on the main repository, unit tests are executed across 5 different versions of PHP:
@@ -171,6 +129,4 @@ After each commit pushed on the main repository, unit tests are executed across
Each version of PHP is tested against the 3 supported database: Sqlite, Mysql and Postgresql.
-That mean we run 15 jobs each time the repository is updated. The execution time is around 25 minutes.
-
The Travis config file `.travis.yml` is located on the root directory of Kanboard.
diff --git a/doc/time-tracking.markdown b/doc/time-tracking.markdown
index 8c65b16a..528f91b3 100644
--- a/doc/time-tracking.markdown
+++ b/doc/time-tracking.markdown
@@ -13,7 +13,7 @@ Tasks have two fields:
- Time estimated
- Time spent
-These values represents hours of work and have to be set manually.
+These values represent hours of work and have to be set manually.
Subtask time tracking
---------------------
@@ -37,7 +37,7 @@ For each subtask, the timer can be stopped/started at any time:
![Subtask timer](http://kanboard.net/screenshots/documentation/subtask-timer.png)
-- The timer doesn't depends of the subtask status
+- The timer doesn't depend of the subtask status
- Each time you start the timer a new record is created in the time tracking table
- Each time you stop the clock the end date is recorded in the time tracking table
- The calculated time spent is rounded to the nearest quarter
diff --git a/doc/transitions.markdown b/doc/transitions.markdown
index c32f696a..b52ccff0 100644
--- a/doc/transitions.markdown
+++ b/doc/transitions.markdown
@@ -5,12 +5,12 @@ Transitions record each movement of the tasks between columns.
![Transitions](http://kanboard.net/screenshots/documentation/transitions.png)
-Available from the task view, you can see those information:
+Available from the task view, you can see that information:
- Date of the action
- Source column
- Destination column
-- Executer (user that move the task)
+- Executor (users that moves the task)
- Time spent in the origin column
Task transition data can also be exported from the project settings page.
diff --git a/doc/translations.markdown b/doc/translations.markdown
index 7a4e325b..00707e1c 100644
--- a/doc/translations.markdown
+++ b/doc/translations.markdown
@@ -5,19 +5,19 @@ How to translate Kanboard to a new language?
--------------------------------------------
- Translations are stored inside the directory `app/Locale`
-- There is sub-directory for each language, by example for the French we have `fr_FR`, Italian `it_IT` etc...
+- There is a subdirectory for each language, for example in French we have `fr_FR`, Italian `it_IT` etc.
- A translation is a PHP file that returns an Array with a key-value pairs
-- The key is the original text in english and the value is the translation for the corresponding language
+- The key is the original text in English and the value is the translation of the corresponding language
- **French translations are always up to date**
- Always use the last version (branch master)
### Create a new translation:
-1. Make a new directory: `app/Locale/xx_XX` by example `app/Locale/fr_CA` for French Canadian
+1. Make a new directory: `app/Locale/xx_XX` for example `app/Locale/fr_CA` for French Canadian
2. Create a new file for the translation: `app/Locale/xx_XX/translations.php`
3. Use the content of the French locales and replace the values
4. Inside the file `app/Model/Config.php`, add a new entry for your translation inside the function `getLanguages()`
-5. Check with your local installation of Kanboard if everything is ok
+5. Check with your local installation of Kanboard if everything is OK
6. Send a [pull-request with Github](https://help.github.com/articles/using-pull-requests/)
How to update an existing translation?
@@ -32,22 +32,11 @@ How to add new translated text in the application?
Translations are displayed with the following functions in the source code:
-- `t()`: dispaly text with HTML escaping
+- `t()`: display text with HTML escaping
- `e()`: display text without HTML escaping
-- `dt()`: display date and time using the `strftime()` function formats
Always use the english version in the source code.
-### Date and time translation
-
-Date strings use the function `strftime()` to format the date.
-
-By example, the original English version can be defined like that `Created on %B %e, %Y at %k:%M %p` and that will output something like that `Created on January 11, 2015 at 15:19 PM`. The French version can be modified to display a different format, `Créé le %d/%m/%Y à %H:%M` and the result will be `Créé le 11/01/2015 à 15:19`.
-
-All formats are available in the [PHP documentation](http://php.net/strftime).
-
-### Placeholders
-
Text strings use the function `sprintf()` to replace elements:
- `%s` is used to replace a string
@@ -76,4 +65,4 @@ From a Unix shell run this command:
./kanboard locale:sync
```
-The French translation is used a reference for other locales.
+The French translation is used a reference to other locales.
diff --git a/doc/ubuntu-installation.markdown b/doc/ubuntu-installation.markdown
index 5f9ee65b..ab4dfe7c 100644
--- a/doc/ubuntu-installation.markdown
+++ b/doc/ubuntu-installation.markdown
@@ -1,17 +1,17 @@
How to install Kanboard on Ubuntu?
==================================
-Ubuntu 14.04 LTS
-----------------
+Ubuntu Trusty 14.04 LTS
+-----------------------
Install Apache and PHP:
```bash
sudo apt-get update
-sudo apt-get install -y php5 php5-sqlite unzip
+sudo apt-get install -y php5 php5-sqlite php5-gd php5-json php5-mcrypt unzip
```
-In case your webserver was running restart to make sure the php modules are reloaded
+In case your web server was running restart to make sure the php modules are reloaded:
```bash
service apache2 restart
@@ -26,3 +26,5 @@ sudo unzip kanboard-latest.zip
sudo chown -R www-data:www-data kanboard/data
sudo rm kanboard-latest.zip
```
+
+Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
diff --git a/doc/update.markdown b/doc/update.markdown
index 0c59a85e..7be8a65a 100644
--- a/doc/update.markdown
+++ b/doc/update.markdown
@@ -1,26 +1,33 @@
-Update
-======
+Upgrade Kanboard to a new version
+=================================
-**Always make a backup of your database before upgrading!**
+Upgrading Kanboard to a newer version is seamless.
+The process can be summarized to simply copy your data folder to the new Kanboard folder.
+Kanboard will run database migrations automatically for you.
+
+Important things to do before updating
+--------------------------------------
+
+- Always make a backup of your data before upgrading
+- Always read the [change log](https://github.com/fguillot/kanboard/blob/master/ChangeLog) to check for breaking changes
+- Always close all user sessions (flush all sessions on the server)
From the archive (stable version)
---------------------------------
-1. Close all sessions (logout)
-2. Rename your actual Kanboard directory (to keep a backup)
-3. Uncompress the new archive and copy your `data` directory to the newly uncompressed directory.
-4. Copy your custom `config.php` (if you created one) to the root of the newly uncompressed directory.
-5. Make the directory `data` writeable by the web server user
-6. Login and check if everything is ok
-7. Remove the old Kanboard directory
-
+1. Decompress the new archive
+2. Copy the content of your data folder into the newly uncompressed directory
+3. Copy your custom `config.php` if you have one
+4. Copy your plugins if necessary
+5. Make sure the directory `data` is writeable by your web server user
+6. Test
+7. Remove your old Kanboard directory
From the repository (development version)
-----------------------------------------
-1. Close all sessions
-2. `git pull`
-3. `composer install`
+1. `git pull`
+2. `composer install`
3. Login and check if everything is ok
Note: This method will install the **current development version**, use at your own risk.
diff --git a/doc/usage-examples.markdown b/doc/usage-examples.markdown
index f2d457a6..5efc6f9a 100644
--- a/doc/usage-examples.markdown
+++ b/doc/usage-examples.markdown
@@ -1,4 +1,4 @@
-Usage examples
+Usage Examples
==============
You can customize your boards according to your business activities:
diff --git a/doc/user-management.markdown b/doc/user-management.markdown
index 942eea09..38033eb3 100644
--- a/doc/user-management.markdown
+++ b/doc/user-management.markdown
@@ -1,60 +1,14 @@
User management
===============
-Roles at the application level
-------------------------------
-
-Kanboard use a basic permission system, there are 3 type of users:
-
-### Administrators
-
-- Access to everything
-
-### Project Administrators
-
-- Can create multi-users and private projects
-- Can convert multi-users and private projects
-- Can see only their own projects
-- Cannot change application settings
-- Cannot manage users
-
-### Standard Users
-
-- Can create only private projects
-- Can see only their own projects
-- Cannot remove projects
-
-Roles at the project level
---------------------------
-
-These role are related to the project permission.
-
-### Project Managers
-
-- Can manage only their own projects
-- Can access to reports and budget section
-
-### Project Members
-
-- Can do any daily operations in their projects (create and move tasks...)
-- Cannot configure projects
-
-Note: Any "Standard User" can be promoted "Project Manager" for a given project, they don't necessary need to be "Project Administrator".
-
-Local and remote users
-----------------------
-
-- A local user is an account that use the database to store credentials. Local users use the login form for the authentication.
-- A remote user is an account that use an external system to store credentials. By example, it can be LDAP, Github or Google accounts. Authentication of these users can be done through the login form or not.
-
Add a new user
--------------
-To add a new user, you must be administrator.
+To add a new user, you must be an administrator.
1. From the dashboard, go to the menu **User Management**
2. On the top, you have a link **New local user** or **New remote user**
-3. Fill the form and save
+3. Fill out the form and save
![New user](http://kanboard.net/screenshots/documentation/new-user.png)
@@ -71,11 +25,11 @@ Edit users
When you go to the **users** menu, you have the list of users, to modify a user click on the **edit link**.
- If you are a regular user, you can change only your own profile
-- You have to be administrator to be able to edit any users
+- You have to be an administrator to be able to edit any users
Remove users
------------
-From the **users** menu, click on the link **remove**. This link is visible only if you are administrator.
+From the **users** menu, click on the link **remove**. This link is visible only if you are administrators.
If you remove a specific user, **tasks assigned to this person will be unassigned** after the operation.
diff --git a/doc/user-mentions.markdown b/doc/user-mentions.markdown
new file mode 100644
index 00000000..156456d6
--- /dev/null
+++ b/doc/user-mentions.markdown
@@ -0,0 +1,17 @@
+User Mentions
+=============
+
+Kanboard offers the possibility to send notifications when someone is mentioned.
+
+If you need to get the attention of someone in a comment or in a task, use the @ symbol followed by their username.
+Kanboard will automatically suggest a list of users:
+
+![User Mention](screenshots/mention-autocomplete.png)
+
+- At the moment, only the task description and the comment text area have this feature enabled.
+- The user mentions works only during tasks and comments creation.
+- To be notified, mentioned users need to be a member of the project.
+- When someone is mentioned, this user will receive a notification.
+- The @username mention is linked to the public user profile.
+
+The notification is sent according to the user settings, it can be an email, a web notification or even a message on Slack/Hipchat/Jabber if you have installed the right plugins.
diff --git a/doc/user-types.markdown b/doc/user-types.markdown
new file mode 100644
index 00000000..8c88a7dd
--- /dev/null
+++ b/doc/user-types.markdown
@@ -0,0 +1,14 @@
+User Types
+==========
+
+In Kanboard there are two types of users:
+
+| Type | Description |
+|---------------|-----------------------------------------------------------------------|
+| Local User | User that stores his password in Kanboard's database |
+| Remote User | User credentials are managed by another system (Example: LDAP server) |
+
+Examples of remote users:
+
+- LDAP user
+- Users authenticated by a reverse-proxy
diff --git a/doc/vagrant.markdown b/doc/vagrant.markdown
index 8c41e141..d11bca84 100644
--- a/doc/vagrant.markdown
+++ b/doc/vagrant.markdown
@@ -41,7 +41,7 @@ The current directory is synced to the Apache document root.
Composer dependencies have to be there, so if you didn't run `composer install` on your host machine you can also do it on the guest machine.
-Each box have its own TCP port:
+Each box has its own TCP port:
- ubuntu: http://localhost:8001/
- debian8: http://localhost:8002/
@@ -61,4 +61,4 @@ Available boxes are:
- `vagrant up centos6`
- `vagrant up freebsd10`
-Any specific configuration have to done manually (Postgres or Mysql).
+Any specific configuration has to be done manually (Postgres or Mysql).
diff --git a/doc/webhooks.markdown b/doc/webhooks.markdown
index f7925350..d6d22400 100644
--- a/doc/webhooks.markdown
+++ b/doc/webhooks.markdown
@@ -1,20 +1,20 @@
-Webhooks
-========
+Web Hooks
+=========
Webhooks are useful to perform actions with external applications.
- Webhooks can be used to create a task by calling a simple URL (You can also do that with the API)
- An external URL can be called automatically when an event occurs in Kanboard (task creation, comment updated, etc)
-How to write a webhook receiver?
---------------------------------
+How to write a web hook receiver?
+---------------------------------
All internal events of Kanboard can be sent to an external URL.
-- The webhook url have to be defined in **Settings > Webhooks > Webhook URL**.
-- When an event is triggered Kanboard call automatically the predefined URL
+- The web hook URL has to be defined in **Settings > Webhooks > Webhook URL**.
+- When an event is triggered Kanboard calls the pre-defined URL automatically
- The data are encoded in JSON format and sent with a POST HTTP request
-- The webhook token is also sent as a query string parameter, so you can check if the request really come from Kanboard.
+- The web hook token is also sent as a query string parameter, so you can check if the request really comes from Kanboard.
- **Your custom URL must answer in less than 1 second**, those requests are synchronous (PHP limitation) and that can slow down the user interface if your script is too slow!
### List of supported events
@@ -270,10 +270,8 @@ Screenshot created:
}
```
-Note: Webhooks configuration and payload have changed since Kanboard >= 1.0.15
-
-How to create a task with a webhook?
-------------------------------------
+How to create a task with a web hook?
+-------------------------------------
Firstly, you have to get the token from the settings page. After that, just call this url from anywhere:
@@ -288,7 +286,7 @@ curl "http://myserver/?controller=webhook&action=task&token=superSecretToken&tit
### Available responses
- When a task is created successfully, Kanboard return the message "OK" in plain text.
-- However if the task creation fail, you will got a "FAILED" message.
+- However if the task creation fail, you will get a "FAILED" message.
- If the token is wrong, you got a "Not Authorized" message and a HTTP status code 401.
### Available parameters
@@ -298,9 +296,9 @@ Base URL: `http://YOUR_SERVER_HOSTNAME/?controller=webhook&action=task`
- `token`: Token displayed on the settings page (required)
- `title`: Task title (required)
- `description`: Task description
-- `color_id`: Supported colors are yellow, blue, green, purple, red, orange and grey
+- `color_id`: Supported colors are yellow, blue, green, purple, red, orange and gray
- `project_id`: Project id (Get the id from the project page)
-- `owner_id`: Assignee (Get the user id from the users page)
+- `owner_id`: Assignee (Get the user id from the user's page)
- `column_id`: Column on the board (Get the column id from the projects page, mouse over on the column name)
Only the token and the title parameters are mandatory. The different id can also be found in the database.
diff --git a/doc/what-is-kanban.markdown b/doc/what-is-kanban.markdown
index 3a37bb7a..5d2eed02 100644
--- a/doc/what-is-kanban.markdown
+++ b/doc/what-is-kanban.markdown
@@ -3,7 +3,7 @@ What is Kanban?
Kanban is a methodology originally developed by Toyota to be more efficient.
-There is only two constraints imposed by Kanban:
+There are only two constraints imposed by Kanban:
- Visualize your workflow
- Limit your work in progress
@@ -12,14 +12,14 @@ Visualize your workflow
-----------------------
- Your work is displayed on a board, you have a clear overview of your project
-- Each column represent a step in your workflow
+- Each column represents a step in your workflow
Bring focus and avoid multitasking
----------------------------------
-- Each phase can have a work in progress limit
+- Each phase can have a work in progress limits
- Limits are great to identify bottlenecks
-- Limits avoid working on too many tasks in the same time
+- Limits avoid working on too many tasks at the same time
Measure performance and improvement
-----------------------------------
@@ -29,4 +29,4 @@ Kanban uses lead and cycle times to measure performance:
- **Lead time**: Time between the task is created and completed
- **Cycle time**: Time between the task is started and completed
-By example, you may have a lead time of 100 days and only have to work 1 hour to complete the task.
+For example, you may have a lead time of 100 days and only have to work 1 hour to complete the task.
diff --git a/doc/windows-apache-installation.markdown b/doc/windows-apache-installation.markdown
index 25d39373..27b6812e 100644
--- a/doc/windows-apache-installation.markdown
+++ b/doc/windows-apache-installation.markdown
@@ -3,7 +3,7 @@ Installation on Windows Server and Apache
This guide will help you to setup step by step Kanboard on a Windows Server with Apache and PHP.
-Note: If you have a 64 bits platform choose "x64" otherwise choose "x86" for 32 bits systems.
+Note: If you have a 64 bits platform choose "x64" otherwise choose "x86" for 32-bit systems.
Visual C++ Redistributable Installation
---------------------------------------
@@ -65,7 +65,6 @@ extension_dir = "C:/php/ext"
Uncomment these PHP modules:
```ini
-extension=php_curl.dll
extension=php_gd2.dll
extension=php_ldap.dll
extension=php_mbstring.dll
@@ -73,13 +72,13 @@ extension=php_openssl.dll
extension=php_pdo_sqlite.dll
```
-Set the timezone:
+Set the time zone:
```ini
date.timezone = America/Montreal
```
-The list of supported timezones can be found in the [PHP documentation](http://php.net/manual/en/timezones.america.php).
+The list of supported time zones can be found in the [PHP documentation](http://php.net/manual/en/timezones.america.php).
Load the PHP module for Apache:
@@ -116,7 +115,7 @@ Kanboard installation
---------------------
- Download the zip file
-- Uncompress the archive in `C:\Apache24\htdocs\kanboard` by example
+- Decompress the archive in `C:\Apache24\htdocs\kanboard` by example
- Open your web browser to use Kanboard http://localhost/kanboard/
- The default credentials are **admin/admin**
@@ -124,3 +123,8 @@ Tested configuration
--------------------
- Windows 2008 R2 / Apache 2.4.12 / PHP 5.6.8
+
+Notes
+-----
+
+- Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
diff --git a/doc/windows-iis-installation.markdown b/doc/windows-iis-installation.markdown
index 84a2e53c..bd4607de 100644
--- a/doc/windows-iis-installation.markdown
+++ b/doc/windows-iis-installation.markdown
@@ -15,7 +15,6 @@ PHP installation
Edit the `php.ini`, uncomment these PHP modules:
```ini
-extension=php_curl.dll
extension=php_gd2.dll
extension=php_ldap.dll
extension=php_mbstring.dll
@@ -23,13 +22,13 @@ extension=php_openssl.dll
extension=php_pdo_sqlite.dll
```
-Set the timezone:
+Set the time zone:
```ini
date.timezone = America/Montreal
```
-The list of supported timezones can be found in the [PHP documentation](http://php.net/manual/en/timezones.america.php).
+The list of supported time zones can be found in the [PHP documentation](http://php.net/manual/en/timezones.america.php).
Check if PHP runs correctly:
@@ -56,7 +55,7 @@ Kanboard installation
---------------------
- Download the zip file
-- Uncompress the archive in `C:\inetpub\wwwroot\kanboard` by example
+- Decompress the archive in `C:\inetpub\wwwroot\kanboard` by example
- Make sure the directory `data` is writable by the IIS user
- Open your web browser to use Kanboard http://localhost/kanboard/
- The default credentials are **admin/admin**
@@ -66,3 +65,9 @@ Tested configurations
- Windows 2008 R2 Standard Edition / IIS 7.5 / PHP 5.5.16
- Windows 2012 Standard Edition / IIS 8.5 / PHP 5.3.29
+
+Notes
+-----
+
+- Some features of Kanboard require that you run [a daily background job](cronjob.markdown).
+
diff --git a/index.php b/index.php
index 2ca0731f..5a8485c5 100644
--- a/index.php
+++ b/index.php
@@ -2,7 +2,7 @@
try {
require __DIR__.'/app/common.php';
- $container['router']->dispatch($_SERVER['REQUEST_URI'], isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '');
+ $container['router']->dispatch();
} catch (Exception $e) {
echo 'Internal Error: '.$e->getMessage();
}
diff --git a/issue_template.md b/issue_template.md
new file mode 100644
index 00000000..196d2e61
--- /dev/null
+++ b/issue_template.md
@@ -0,0 +1,24 @@
+### Expected behaviour
+
+Tell us what should happen
+
+
+### Actual behaviour
+
+Tell us what happens instead
+
+
+### Steps to reproduce
+
+1.
+2.
+3.
+
+
+### Configuration
+
+- Kanboard version:
+- Database type and version:
+- PHP version:
+- OS:
+- Browser:
diff --git a/jsonrpc.php b/jsonrpc.php
index 6778603f..d2163347 100644
--- a/jsonrpc.php
+++ b/jsonrpc.php
@@ -8,6 +8,7 @@ use Kanboard\Api\Me;
use Kanboard\Api\Action;
use Kanboard\Api\App;
use Kanboard\Api\Board;
+use Kanboard\Api\Column;
use Kanboard\Api\Category;
use Kanboard\Api\Comment;
use Kanboard\Api\File;
@@ -19,6 +20,8 @@ use Kanboard\Api\Swimlane;
use Kanboard\Api\Task;
use Kanboard\Api\TaskLink;
use Kanboard\Api\User;
+use Kanboard\Api\Group;
+use Kanboard\Api\GroupMember;
$server = new Server;
$server->setAuthenticationHeader(API_AUTHENTICATION_HEADER);
@@ -28,6 +31,7 @@ $server->attach(new Me($container));
$server->attach(new Action($container));
$server->attach(new App($container));
$server->attach(new Board($container));
+$server->attach(new Column($container));
$server->attach(new Category($container));
$server->attach(new Comment($container));
$server->attach(new File($container));
@@ -39,5 +43,7 @@ $server->attach(new Swimlane($container));
$server->attach(new Task($container));
$server->attach(new TaskLink($container));
$server->attach(new User($container));
+$server->attach(new Group($container));
+$server->attach(new GroupMember($container));
echo $server->execute();
diff --git a/kanboard b/kanboard
index 2911a506..5046181d 100755
--- a/kanboard
+++ b/kanboard
@@ -13,8 +13,10 @@ use Kanboard\Console\ProjectDailyColumnStatsExport;
use Kanboard\Console\TransitionExport;
use Kanboard\Console\LocaleSync;
use Kanboard\Console\LocaleComparator;
+use Kanboard\Console\TaskTrigger;
+use Kanboard\Console\Cronjob;
-$container['dispatcher']->dispatch('console.bootstrap', new Event);
+$container['dispatcher']->dispatch('app.bootstrap', new Event);
$application = new Application('Kanboard', APP_VERSION);
$application->add(new TaskOverdueNotification($container));
@@ -25,4 +27,6 @@ $application->add(new ProjectDailyColumnStatsExport($container));
$application->add(new TransitionExport($container));
$application->add(new LocaleSync($container));
$application->add(new LocaleComparator($container));
+$application->add(new TaskTrigger($container));
+$application->add(new Cronjob($container));
$application->run();
diff --git a/tests/functionals.mysql.xml b/tests/integration.mysql.xml
index a8877f92..30769371 100644
--- a/tests/functionals.mysql.xml
+++ b/tests/integration.mysql.xml
@@ -1,7 +1,7 @@
<phpunit stopOnError="true" stopOnFailure="true" colors="true">
<testsuites>
<testsuite name="Kanboard">
- <directory>functionals</directory>
+ <directory>integration</directory>
</testsuite>
</testsuites>
<php>
diff --git a/tests/functionals.postgres.xml b/tests/integration.postgres.xml
index 61da8574..ed8a3de3 100644
--- a/tests/functionals.postgres.xml
+++ b/tests/integration.postgres.xml
@@ -1,7 +1,7 @@
<phpunit stopOnError="true" stopOnFailure="true" colors="true">
<testsuites>
<testsuite name="Kanboard">
- <directory>functionals</directory>
+ <directory>integration</directory>
</testsuite>
</testsuites>
<php>
diff --git a/tests/functionals.sqlite.xml b/tests/integration.sqlite.xml
index 9da59b20..1964f822 100644
--- a/tests/functionals.sqlite.xml
+++ b/tests/integration.sqlite.xml
@@ -1,7 +1,7 @@
<phpunit stopOnError="true" stopOnFailure="true" colors="true">
<testsuites>
<testsuite name="Kanboard">
- <directory>functionals</directory>
+ <directory>integration</directory>
</testsuite>
</testsuites>
<php>
diff --git a/tests/functionals/ApiTest.php b/tests/integration/ApiTest.php
index 7576f031..5fed0368 100644
--- a/tests/functionals/ApiTest.php
+++ b/tests/integration/ApiTest.php
@@ -45,16 +45,6 @@ class Api extends PHPUnit_Framework_TestCase
return $tasks[0]['id'];
}
- public function testGetTimezone()
- {
- $this->assertEquals('Europe/Paris', $this->client->getTimezone());
- }
-
- public function testGetVersion()
- {
- $this->assertEquals('master', $this->client->getVersion());
- }
-
public function testRemoveAll()
{
$projects = $this->client->getAllProjects();
@@ -180,190 +170,31 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertCount(0, $activities);
}
- public function testGetBoard()
- {
- $board = $this->client->getBoard(1);
- $this->assertTrue(is_array($board));
- $this->assertEquals(1, count($board));
- $this->assertEquals('Default swimlane', $board[0]['name']);
- $this->assertEquals(4, count($board[0]['columns']));
- }
-
- public function testGetColumns()
- {
- $columns = $this->client->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals(4, count($columns));
- $this->assertEquals('Done', $columns[3]['title']);
- }
-
- public function testMoveColumnUp()
- {
- $this->assertTrue($this->client->moveColumnUp(1, 4));
-
- $columns = $this->client->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals('Done', $columns[2]['title']);
- $this->assertEquals('Work in progress', $columns[3]['title']);
- }
-
- public function testMoveColumnDown()
- {
- $this->assertTrue($this->client->moveColumnDown(1, 4));
-
- $columns = $this->client->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals('Work in progress', $columns[2]['title']);
- $this->assertEquals('Done', $columns[3]['title']);
- }
-
- public function testUpdateColumn()
- {
- $this->assertTrue($this->client->updateColumn(4, 'Boo', 2));
-
- $columns = $this->client->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals('Boo', $columns[3]['title']);
- $this->assertEquals(2, $columns[3]['task_limit']);
- }
-
- public function testAddColumn()
- {
- $column_id = $this->client->addColumn(1, 'New column');
-
- $this->assertNotFalse($column_id);
- $this->assertInternalType('int', $column_id);
- $this->assertTrue($column_id > 0);
-
- $columns = $this->client->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals(5, count($columns));
- $this->assertEquals('New column', $columns[4]['title']);
- }
-
- public function testRemoveColumn()
- {
- $this->assertTrue($this->client->removeColumn(5));
-
- $columns = $this->client->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals(4, count($columns));
- }
-
- public function testGetDefaultSwimlane()
- {
- $swimlane = $this->client->getDefaultSwimlane(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals('Default swimlane', $swimlane['default_swimlane']);
- }
-
- public function testAddSwimlane()
- {
- $swimlane_id = $this->client->addSwimlane(1, 'Swimlane 1');
- $this->assertNotFalse($swimlane_id);
- $this->assertInternalType('int', $swimlane_id);
-
- $swimlane = $this->client->getSwimlaneById($swimlane_id);
- $this->assertNotEmpty($swimlane);
- $this->assertInternalType('array', $swimlane);
- $this->assertEquals('Swimlane 1', $swimlane['name']);
- }
-
- public function testGetSwimlane()
- {
- $swimlane = $this->client->getSwimlane(1);
- $this->assertNotEmpty($swimlane);
- $this->assertInternalType('array', $swimlane);
- $this->assertEquals('Swimlane 1', $swimlane['name']);
- }
-
- public function testUpdateSwimlane()
- {
- $swimlane = $this->client->getSwimlaneByName(1, 'Swimlane 1');
- $this->assertNotEmpty($swimlane);
- $this->assertInternalType('array', $swimlane);
- $this->assertEquals(1, $swimlane['id']);
- $this->assertEquals('Swimlane 1', $swimlane['name']);
-
- $this->assertTrue($this->client->updateSwimlane($swimlane['id'], 'Another swimlane'));
-
- $swimlane = $this->client->getSwimlaneById($swimlane['id']);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals('Another swimlane', $swimlane['name']);
- }
-
- public function testDisableSwimlane()
- {
- $this->assertTrue($this->client->disableSwimlane(1, 1));
-
- $swimlane = $this->client->getSwimlaneById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(0, $swimlane['is_active']);
- }
-
- public function testEnableSwimlane()
- {
- $this->assertTrue($this->client->enableSwimlane(1, 1));
-
- $swimlane = $this->client->getSwimlaneById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- }
-
- public function testGetAllSwimlanes()
+ public function testCreateTaskWithWrongMember()
{
- $this->assertNotFalse($this->client->addSwimlane(1, 'Swimlane A'));
-
- $swimlanes = $this->client->getAllSwimlanes(1);
- $this->assertNotEmpty($swimlanes);
- $this->assertCount(2, $swimlanes);
- $this->assertEquals('Another swimlane', $swimlanes[0]['name']);
- $this->assertEquals('Swimlane A', $swimlanes[1]['name']);
- }
+ $task = array(
+ 'title' => 'Task #1',
+ 'color_id' => 'blue',
+ 'owner_id' => 1,
+ 'project_id' => 1,
+ 'column_id' => 2,
+ );
- public function testGetActiveSwimlane()
- {
- $this->assertTrue($this->client->disableSwimlane(1, 1));
+ $task_id = $this->client->createTask($task);
- $swimlanes = $this->client->getActiveSwimlanes(1);
- $this->assertNotEmpty($swimlanes);
- $this->assertCount(2, $swimlanes);
- $this->assertEquals('Default swimlane', $swimlanes[0]['name']);
- $this->assertEquals('Swimlane A', $swimlanes[1]['name']);
+ $this->assertFalse($task_id);
}
- public function testMoveSwimlaneUp()
+ public function testGetAllowedUsers()
{
- $this->assertTrue($this->client->enableSwimlane(1, 1));
- $this->assertTrue($this->client->moveSwimlaneUp(1, 1));
-
- $swimlanes = $this->client->getActiveSwimlanes(1);
- $this->assertNotEmpty($swimlanes);
- $this->assertCount(3, $swimlanes);
- $this->assertEquals('Default swimlane', $swimlanes[0]['name']);
- $this->assertEquals('Another swimlane', $swimlanes[1]['name']);
- $this->assertEquals('Swimlane A', $swimlanes[2]['name']);
-
- $this->assertTrue($this->client->moveSwimlaneUp(1, 2));
-
- $swimlanes = $this->client->getActiveSwimlanes(1);
- $this->assertNotEmpty($swimlanes);
- $this->assertCount(3, $swimlanes);
- $this->assertEquals('Default swimlane', $swimlanes[0]['name']);
- $this->assertEquals('Swimlane A', $swimlanes[1]['name']);
- $this->assertEquals('Another swimlane', $swimlanes[2]['name']);
+ $users = $this->client->getMembers(1);
+ $this->assertNotFalse($users);
+ $this->assertEquals(array(), $users);
}
- public function testMoveSwimlaneDown()
+ public function testAddMember()
{
- $this->assertTrue($this->client->moveSwimlaneDown(1, 2));
-
- $swimlanes = $this->client->getActiveSwimlanes(1);
- $this->assertNotEmpty($swimlanes);
- $this->assertCount(3, $swimlanes);
- $this->assertEquals('Default swimlane', $swimlanes[0]['name']);
- $this->assertEquals('Another swimlane', $swimlanes[1]['name']);
- $this->assertEquals('Swimlane A', $swimlanes[2]['name']);
+ $this->assertTrue($this->client->allowUser(1, 1));
}
public function testCreateTask()
@@ -447,18 +278,6 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotEquals($moved_timestamp, $task['date_moved']);
}
- public function testRemoveSwimlane()
- {
- $this->assertTrue($this->client->removeSwimlane(1, 2));
-
- $task = $this->client->getTask($this->getTaskId());
- $this->assertNotFalse($task);
- $this->assertTrue(is_array($task));
- $this->assertEquals(1, $task['position']);
- $this->assertEquals(4, $task['column_id']);
- $this->assertEquals(0, $task['swimlane_id']);
- }
-
public function testUpdateTask()
{
$task = $this->client->getTask(1);
@@ -504,6 +323,21 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertTrue($user_id > 0);
}
+ public function testCreateManagerUser()
+ {
+ $user = array(
+ 'username' => 'manager',
+ 'name' => 'Manager',
+ 'password' => '123456',
+ 'role' => 'app-manager'
+ );
+
+ $user_id = $this->client->execute('createUser', $user);
+ $this->assertNotFalse($user_id);
+ $this->assertInternalType('int', $user_id);
+ $this->assertTrue($user_id > 0);
+ }
+
/**
* @expectedException InvalidArgumentException
*/
@@ -524,9 +358,27 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertTrue(is_array($user));
$this->assertEquals('toto', $user['username']);
+ $user = $this->client->getUser(3);
+ $this->assertNotEmpty($user);
+ $this->assertEquals('app-manager', $user['role']);
+
$this->assertNull($this->client->getUser(2222));
}
+ public function testGetUserByName()
+ {
+ $user = $this->client->getUserByName('toto');
+ $this->assertNotFalse($user);
+ $this->assertTrue(is_array($user));
+ $this->assertEquals(2, $user['id']);
+
+ $user = $this->client->getUserByName('manager');
+ $this->assertNotEmpty($user);
+ $this->assertEquals('app-manager', $user['role']);
+
+ $this->assertNull($this->client->getUserByName('nonexistantusername'));
+ }
+
public function testUpdateUser()
{
$user = array();
@@ -554,20 +406,13 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertEquals('titi@localhost', $user['email']);
}
- public function testGetAllowedUsers()
- {
- $users = $this->client->getMembers(1);
- $this->assertNotFalse($users);
- $this->assertEquals(array(), $users);
- }
-
public function testAllowedUser()
{
$this->assertTrue($this->client->allowUser(1, 2));
$users = $this->client->getMembers(1);
$this->assertNotFalse($users);
- $this->assertEquals(array(2 => 'Titi'), $users);
+ $this->assertEquals(array(1 => 'admin', 2 => 'Titi'), $users);
}
public function testRevokeUser()
@@ -576,7 +421,7 @@ class Api extends PHPUnit_Framework_TestCase
$users = $this->client->getMembers(1);
$this->assertNotFalse($users);
- $this->assertEquals(array(), $users);
+ $this->assertEquals(array(1 => 'admin'), $users);
}
public function testCreateComment()
@@ -836,7 +681,7 @@ class Api extends PHPUnit_Framework_TestCase
$actions = $this->client->getAvailableActions();
$this->assertNotEmpty($actions);
$this->assertInternalType('array', $actions);
- $this->assertArrayHasKey('TaskLogMoveAnotherColumn', $actions);
+ $this->assertArrayHasKey('\Kanboard\Action\TaskCloseColumn', $actions);
}
public function testGetAvailableActionEvents()
@@ -849,7 +694,7 @@ class Api extends PHPUnit_Framework_TestCase
public function testGetCompatibleActionEvents()
{
- $events = $this->client->getCompatibleActionEvents('TaskClose');
+ $events = $this->client->getCompatibleActionEvents('\Kanboard\Action\TaskCloseColumn');
$this->assertNotEmpty($events);
$this->assertInternalType('array', $events);
$this->assertArrayHasKey('task.move.column', $events);
@@ -857,7 +702,7 @@ class Api extends PHPUnit_Framework_TestCase
public function testCreateAction()
{
- $action_id = $this->client->createAction(1, 'task.move.column', 'TaskClose', array('column_id' => 1));
+ $action_id = $this->client->createAction(1, 'task.move.column', '\Kanboard\Action\TaskCloseColumn', array('column_id' => 1));
$this->assertNotFalse($action_id);
$this->assertEquals(1, $action_id);
}
diff --git a/tests/integration/AppTest.php b/tests/integration/AppTest.php
new file mode 100644
index 00000000..6575fbb8
--- /dev/null
+++ b/tests/integration/AppTest.php
@@ -0,0 +1,34 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class AppTest extends Base
+{
+ public function testGetTimezone()
+ {
+ $this->assertEquals('UTC', $this->app->getTimezone());
+ }
+
+ public function testGetVersion()
+ {
+ $this->assertEquals('master', $this->app->getVersion());
+ }
+
+ public function testGetApplicationRoles()
+ {
+ $roles = $this->app->getApplicationRoles();
+ $this->assertCount(3, $roles);
+ $this->assertEquals('Administrator', $roles['app-admin']);
+ $this->assertEquals('Manager', $roles['app-manager']);
+ $this->assertEquals('User', $roles['app-user']);
+ }
+
+ public function testGetProjectRoles()
+ {
+ $roles = $this->app->getProjectRoles();
+ $this->assertCount(3, $roles);
+ $this->assertEquals('Project Manager', $roles['project-manager']);
+ $this->assertEquals('Project Member', $roles['project-member']);
+ $this->assertEquals('Project Viewer', $roles['project-viewer']);
+ }
+}
diff --git a/tests/integration/Base.php b/tests/integration/Base.php
new file mode 100644
index 00000000..983d0ed9
--- /dev/null
+++ b/tests/integration/Base.php
@@ -0,0 +1,62 @@
+<?php
+
+require_once __DIR__.'/../../vendor/autoload.php';
+
+abstract class Base extends PHPUnit_Framework_TestCase
+{
+ protected $app = null;
+ protected $admin = null;
+ protected $user = null;
+
+ public static function setUpBeforeClass()
+ {
+ if (DB_DRIVER === 'sqlite') {
+ @unlink(DB_FILENAME);
+ } elseif (DB_DRIVER === 'mysql') {
+ $pdo = new PDO('mysql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD);
+ $pdo->exec('DROP DATABASE '.DB_NAME);
+ $pdo->exec('CREATE DATABASE '.DB_NAME);
+ $pdo = null;
+ } elseif (DB_DRIVER === 'postgres') {
+ $pdo = new PDO('pgsql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD);
+ $pdo->exec('DROP DATABASE '.DB_NAME);
+ $pdo->exec('CREATE DATABASE '.DB_NAME.' WITH OWNER '.DB_USERNAME);
+ $pdo = null;
+ }
+
+ $service = new Kanboard\ServiceProvider\DatabaseProvider;
+
+ $db = $service->getInstance();
+ $db->table('settings')->eq('option', 'api_token')->update(array('value' => API_KEY));
+ $db->closeConnection();
+ }
+
+ public function setUp()
+ {
+ $this->app = new JsonRPC\Client(API_URL);
+ $this->app->authentication('jsonrpc', API_KEY);
+ // $this->app->debug = true;
+
+ $this->admin = new JsonRPC\Client(API_URL);
+ $this->admin->authentication('admin', 'admin');
+ // $this->admin->debug = true;
+
+ $this->user = new JsonRPC\Client(API_URL);
+ $this->user->authentication('user', 'password');
+ // $this->user->debug = true;
+ }
+
+ protected function getProjectId()
+ {
+ $projects = $this->app->getAllProjects();
+ $this->assertNotEmpty($projects);
+ return $projects[0]['id'];
+ }
+
+ protected function getGroupId()
+ {
+ $groups = $this->app->getAllGroups();
+ $this->assertNotEmpty($groups);
+ return $groups[0]['id'];
+ }
+}
diff --git a/tests/integration/BoardTest.php b/tests/integration/BoardTest.php
new file mode 100644
index 00000000..bf8d50b9
--- /dev/null
+++ b/tests/integration/BoardTest.php
@@ -0,0 +1,21 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class BoardTest extends Base
+{
+ public function testCreateProject()
+ {
+ $this->assertEquals(1, $this->app->createProject('A project'));
+ }
+
+ public function testGetBoard()
+ {
+ $board = $this->app->getBoard(1);
+ $this->assertCount(1, $board);
+ $this->assertEquals('Default swimlane', $board[0]['name']);
+
+ $this->assertCount(4, $board[0]['columns']);
+ $this->assertEquals('Ready', $board[0]['columns'][1]['title']);
+ }
+}
diff --git a/tests/integration/ColumnTest.php b/tests/integration/ColumnTest.php
new file mode 100644
index 00000000..6d02afc0
--- /dev/null
+++ b/tests/integration/ColumnTest.php
@@ -0,0 +1,65 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class ColumnTest extends Base
+{
+ public function testCreateProject()
+ {
+ $this->assertEquals(1, $this->app->createProject('A project'));
+ }
+
+ public function testGetColumns()
+ {
+ $columns = $this->app->getColumns($this->getProjectId());
+ $this->assertCount(4, $columns);
+ $this->assertEquals('Done', $columns[3]['title']);
+ }
+
+ public function testUpdateColumn()
+ {
+ $this->assertTrue($this->app->updateColumn(4, 'Boo', 2));
+
+ $columns = $this->app->getColumns($this->getProjectId());
+ $this->assertEquals('Boo', $columns[3]['title']);
+ $this->assertEquals(2, $columns[3]['task_limit']);
+ }
+
+ public function testAddColumn()
+ {
+ $column_id = $this->app->addColumn($this->getProjectId(), 'New column');
+
+ $this->assertNotFalse($column_id);
+ $this->assertInternalType('int', $column_id);
+ $this->assertTrue($column_id > 0);
+
+ $columns = $this->app->getColumns($this->getProjectId());
+ $this->assertCount(5, $columns);
+ $this->assertEquals('New column', $columns[4]['title']);
+ }
+
+ public function testRemoveColumn()
+ {
+ $this->assertTrue($this->app->removeColumn(5));
+
+ $columns = $this->app->getColumns($this->getProjectId());
+ $this->assertCount(4, $columns);
+ }
+
+ public function testChangeColumnPosition()
+ {
+ $this->assertTrue($this->app->changeColumnPosition($this->getProjectId(), 1, 3));
+
+ $columns = $this->app->getColumns($this->getProjectId());
+ $this->assertCount(4, $columns);
+
+ $this->assertEquals('Ready', $columns[0]['title']);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals('Work in progress', $columns[1]['title']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals('Backlog', $columns[2]['title']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals('Boo', $columns[3]['title']);
+ $this->assertEquals(4, $columns[3]['position']);
+ }
+}
diff --git a/tests/integration/GroupMemberTest.php b/tests/integration/GroupMemberTest.php
new file mode 100644
index 00000000..e84c0734
--- /dev/null
+++ b/tests/integration/GroupMemberTest.php
@@ -0,0 +1,39 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class GroupMemberTest extends Base
+{
+ public function testAddMember()
+ {
+ $this->assertNotFalse($this->app->createGroup('My Group A'));
+ $this->assertNotFalse($this->app->createGroup('My Group B'));
+
+ $groupId = $this->getGroupId();
+ $this->assertTrue($this->app->addGroupMember($groupId, 1));
+ }
+
+ public function testGetMembers()
+ {
+ $groups = $this->app->getAllGroups();
+ $members = $this->app->getGroupMembers($groups[0]['id']);
+ $this->assertCount(1, $members);
+ $this->assertEquals('admin', $members[0]['username']);
+
+ $this->assertSame(array(), $this->app->getGroupMembers($groups[1]['id']));
+ }
+
+ public function testIsGroupMember()
+ {
+ $groupId = $this->getGroupId();
+ $this->assertTrue($this->app->isGroupMember($groupId, 1));
+ $this->assertFalse($this->app->isGroupMember($groupId, 2));
+ }
+
+ public function testRemove()
+ {
+ $groupId = $this->getGroupId();
+ $this->assertTrue($this->app->removeGroupMember($groupId, 1));
+ $this->assertFalse($this->app->isGroupMember($groupId, 1));
+ }
+}
diff --git a/tests/integration/GroupTest.php b/tests/integration/GroupTest.php
new file mode 100644
index 00000000..7a5bccc9
--- /dev/null
+++ b/tests/integration/GroupTest.php
@@ -0,0 +1,48 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class GroupTest extends Base
+{
+ public function testCreateGroup()
+ {
+ $this->assertNotFalse($this->app->createGroup('My Group A'));
+ $this->assertNotFalse($this->app->createGroup('My Group B', '1234'));
+ }
+
+ public function testGetter()
+ {
+ $groups = $this->app->getAllGroups();
+ $this->assertCount(2, $groups);
+ $this->assertEquals('My Group A', $groups[0]['name']);
+ $this->assertEquals('', $groups[0]['external_id']);
+ $this->assertEquals('My Group B', $groups[1]['name']);
+ $this->assertEquals('1234', $groups[1]['external_id']);
+
+ $group = $this->app->getGroup($groups[0]['id']);
+ $this->assertNotEmpty($group);
+ $this->assertEquals('My Group A', $group['name']);
+ $this->assertEquals('', $group['external_id']);
+ }
+
+ public function testUpdate()
+ {
+ $groups = $this->app->getAllGroups();
+
+ $this->assertTrue($this->app->updateGroup(array('group_id' => $groups[0]['id'], 'name' => 'ABC', 'external_id' => 'something')));
+ $this->assertTrue($this->app->updateGroup(array('group_id' => $groups[1]['id'], 'external_id' => '')));
+
+ $groups = $this->app->getAllGroups();
+ $this->assertEquals('ABC', $groups[0]['name']);
+ $this->assertEquals('something', $groups[0]['external_id']);
+ $this->assertEquals('', $groups[1]['external_id']);
+ }
+
+ public function testRemove()
+ {
+ $groups = $this->app->getAllGroups();
+ $this->assertTrue($this->app->removeGroup($groups[0]['id']));
+ $this->assertTrue($this->app->removeGroup($groups[1]['id']));
+ $this->assertSame(array(), $this->app->getAllGroups());
+ }
+}
diff --git a/tests/functionals/UserApiTest.php b/tests/integration/MeTest.php
index 8a80c706..21f61756 100644
--- a/tests/functionals/UserApiTest.php
+++ b/tests/integration/MeTest.php
@@ -1,51 +1,9 @@
<?php
-require_once __DIR__.'/../../vendor/autoload.php';
+require_once __DIR__.'/Base.php';
-class UserApi extends PHPUnit_Framework_TestCase
+class MeTest extends Base
{
- private $app = null;
- private $admin = null;
- private $user = null;
-
- public static function setUpBeforeClass()
- {
- if (DB_DRIVER === 'sqlite') {
- @unlink(DB_FILENAME);
- } elseif (DB_DRIVER === 'mysql') {
- $pdo = new PDO('mysql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD);
- $pdo->exec('DROP DATABASE '.DB_NAME);
- $pdo->exec('CREATE DATABASE '.DB_NAME);
- $pdo = null;
- } elseif (DB_DRIVER === 'postgres') {
- $pdo = new PDO('pgsql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD);
- $pdo->exec('DROP DATABASE '.DB_NAME);
- $pdo->exec('CREATE DATABASE '.DB_NAME.' WITH OWNER '.DB_USERNAME);
- $pdo = null;
- }
-
- $service = new Kanboard\ServiceProvider\DatabaseProvider;
-
- $db = $service->getInstance();
- $db->table('settings')->eq('option', 'api_token')->update(array('value' => API_KEY));
- $db->closeConnection();
- }
-
- public function setUp()
- {
- $this->app = new JsonRPC\Client(API_URL);
- $this->app->authentication('jsonrpc', API_KEY);
- // $this->app->debug = true;
-
- $this->admin = new JsonRPC\Client(API_URL);
- $this->admin->authentication('admin', 'admin');
- // $this->admin->debug = true;
-
- $this->user = new JsonRPC\Client(API_URL);
- $this->user->authentication('user', 'password');
- // $this->user->debug = true;
- }
-
public function testCreateProject()
{
$this->assertEquals(1, $this->app->createProject('team project'));
@@ -163,6 +121,12 @@ class UserApi extends PHPUnit_Framework_TestCase
$this->assertEquals(2, $this->admin->createTask('my admin title', 1));
}
+ public function testCreateTaskWithWrongMember()
+ {
+ $this->assertFalse($this->user->createTask(array('title' => 'something', 'project_id' => 2, 'owner_id' => 1)));
+ $this->assertFalse($this->app->createTask(array('title' => 'something', 'project_id' => 1, 'owner_id' => 2)));
+ }
+
public function testGetTask()
{
$task = $this->user->getTask(1);
@@ -218,6 +182,11 @@ class UserApi extends PHPUnit_Framework_TestCase
$this->assertTrue($this->user->moveTaskPosition(2, 1, 2, 1));
}
+ public function testUpdateTaskWithWrongMember()
+ {
+ $this->assertFalse($this->user->updateTask(array('id' => 1, 'title' => 'new title', 'reference' => 'test', 'owner_id' => 1)));
+ }
+
public function testUpdateTask()
{
$this->assertTrue($this->user->updateTask(array('id' => 1, 'title' => 'new title', 'reference' => 'test', 'owner_id' => 2)));
diff --git a/tests/integration/ProjectPermissionTest.php b/tests/integration/ProjectPermissionTest.php
new file mode 100644
index 00000000..b06ad4ad
--- /dev/null
+++ b/tests/integration/ProjectPermissionTest.php
@@ -0,0 +1,64 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class ProjectPermissionTest extends Base
+{
+ public function testGetProjectUsers()
+ {
+ $this->assertNotFalse($this->app->createProject('Test'));
+ $this->assertNotFalse($this->app->createGroup('Test'));
+
+ $projectId = $this->getProjectId();
+ $groupId = $this->getGroupId();
+
+ $this->assertTrue($this->app->addGroupMember($projectId, $groupId));
+ $this->assertSame(array(), $this->app->getProjectUsers($projectId));
+ }
+
+ public function testProjectUser()
+ {
+ $projectId = $this->getProjectId();
+ $this->assertTrue($this->app->addProjectUser($projectId, 1));
+
+ $users = $this->app->getProjectUsers($projectId);
+ $this->assertCount(1, $users);
+ $this->assertEquals('admin', $users[1]);
+
+ $users = $this->app->getAssignableUsers($projectId);
+ $this->assertCount(1, $users);
+ $this->assertEquals('admin', $users[1]);
+
+ $this->assertTrue($this->app->changeProjectUserRole($projectId, 1, 'project-viewer'));
+
+ $users = $this->app->getAssignableUsers($projectId);
+ $this->assertCount(0, $users);
+
+ $this->assertTrue($this->app->removeProjectUser($projectId, 1));
+ $this->assertSame(array(), $this->app->getProjectUsers($projectId));
+ }
+
+ public function testProjectGroup()
+ {
+ $projectId = $this->getProjectId();
+ $groupId = $this->getGroupId();
+
+ $this->assertTrue($this->app->addProjectGroup($projectId, $groupId));
+
+ $users = $this->app->getProjectUsers($projectId);
+ $this->assertCount(1, $users);
+ $this->assertEquals('admin', $users[1]);
+
+ $users = $this->app->getAssignableUsers($projectId);
+ $this->assertCount(1, $users);
+ $this->assertEquals('admin', $users[1]);
+
+ $this->assertTrue($this->app->changeProjectGroupRole($projectId, $groupId, 'project-viewer'));
+
+ $users = $this->app->getAssignableUsers($projectId);
+ $this->assertCount(0, $users);
+
+ $this->assertTrue($this->app->removeProjectGroup($projectId, 1));
+ $this->assertSame(array(), $this->app->getProjectUsers($projectId));
+ }
+}
diff --git a/tests/integration/SwimlaneTest.php b/tests/integration/SwimlaneTest.php
new file mode 100644
index 00000000..88747204
--- /dev/null
+++ b/tests/integration/SwimlaneTest.php
@@ -0,0 +1,103 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class SwimlaneTest extends Base
+{
+ public function testCreateProject()
+ {
+ $this->assertEquals(1, $this->app->createProject('A project'));
+ }
+
+ public function testGetDefaultSwimlane()
+ {
+ $swimlane = $this->app->getDefaultSwimlane(1);
+ $this->assertNotEmpty($swimlane);
+ $this->assertEquals('Default swimlane', $swimlane['default_swimlane']);
+ }
+
+ public function testAddSwimlane()
+ {
+ $swimlane_id = $this->app->addSwimlane(1, 'Swimlane 1');
+ $this->assertNotFalse($swimlane_id);
+ $this->assertInternalType('int', $swimlane_id);
+
+ $swimlane = $this->app->getSwimlaneById($swimlane_id);
+ $this->assertNotEmpty($swimlane);
+ $this->assertInternalType('array', $swimlane);
+ $this->assertEquals('Swimlane 1', $swimlane['name']);
+ }
+
+ public function testGetSwimlane()
+ {
+ $swimlane = $this->app->getSwimlane(1);
+ $this->assertInternalType('array', $swimlane);
+ $this->assertEquals('Swimlane 1', $swimlane['name']);
+ }
+
+ public function testUpdateSwimlane()
+ {
+ $swimlane = $this->app->getSwimlaneByName(1, 'Swimlane 1');
+ $this->assertInternalType('array', $swimlane);
+ $this->assertEquals(1, $swimlane['id']);
+ $this->assertEquals('Swimlane 1', $swimlane['name']);
+
+ $this->assertTrue($this->app->updateSwimlane($swimlane['id'], 'Another swimlane'));
+
+ $swimlane = $this->app->getSwimlaneById($swimlane['id']);
+ $this->assertEquals('Another swimlane', $swimlane['name']);
+ }
+
+ public function testDisableSwimlane()
+ {
+ $this->assertTrue($this->app->disableSwimlane(1, 1));
+
+ $swimlane = $this->app->getSwimlaneById(1);
+ $this->assertEquals(0, $swimlane['is_active']);
+ }
+
+ public function testEnableSwimlane()
+ {
+ $this->assertTrue($this->app->enableSwimlane(1, 1));
+
+ $swimlane = $this->app->getSwimlaneById(1);
+ $this->assertEquals(1, $swimlane['is_active']);
+ }
+
+ public function testGetAllSwimlanes()
+ {
+ $this->assertNotFalse($this->app->addSwimlane(1, 'Swimlane A'));
+
+ $swimlanes = $this->app->getAllSwimlanes(1);
+ $this->assertCount(2, $swimlanes);
+ $this->assertEquals('Another swimlane', $swimlanes[0]['name']);
+ $this->assertEquals('Swimlane A', $swimlanes[1]['name']);
+ }
+
+ public function testGetActiveSwimlane()
+ {
+ $this->assertTrue($this->app->disableSwimlane(1, 1));
+
+ $swimlanes = $this->app->getActiveSwimlanes(1);
+ $this->assertCount(2, $swimlanes);
+ $this->assertEquals('Default swimlane', $swimlanes[0]['name']);
+ $this->assertEquals('Swimlane A', $swimlanes[1]['name']);
+ }
+
+ public function testRemoveSwimlane()
+ {
+ $this->assertTrue($this->app->removeSwimlane(1, 2));
+ }
+
+ public function testChangePosition()
+ {
+ $this->assertNotFalse($this->app->addSwimlane(1, 'Swimlane 1'));
+ $this->assertNotFalse($this->app->addSwimlane(1, 'Swimlane 2'));
+
+ $swimlanes = $this->app->getAllSwimlanes(1);
+ $this->assertCount(3, $swimlanes);
+
+ $this->assertTrue($this->app->changeSwimlanePosition(1, 1, 3));
+ $this->assertFalse($this->app->changeSwimlanePosition(1, 1, 6));
+ }
+}
diff --git a/tests/integration/TaskTest.php b/tests/integration/TaskTest.php
new file mode 100644
index 00000000..6d500da4
--- /dev/null
+++ b/tests/integration/TaskTest.php
@@ -0,0 +1,96 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class TaskTest extends Base
+{
+ public function testChangeAssigneeToAssignableUser()
+ {
+ $project_id = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id);
+
+ $user_id = $this->app->createUser('user0', 'password');
+ $this->assertNotFalse($user_id);
+
+ $this->assertTrue($this->app->addProjectUser($project_id, $user_id, 'project-member'));
+
+ $task_id = $this->app->createTask(array('project_id' => $project_id, 'title' => 'My task'));
+ $this->assertNotFalse($task_id);
+
+ $this->assertTrue($this->app->updateTask(array('id' => $task_id, 'project_id' => $project_id, 'owner_id' => $user_id)));
+
+ $task = $this->app->getTask($task_id);
+ $this->assertEquals($user_id, $task['owner_id']);
+ }
+
+ public function testChangeAssigneeToNotAssignableUser()
+ {
+ $project_id = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id);
+
+ $task_id = $this->app->createTask(array('project_id' => $project_id, 'title' => 'My task'));
+ $this->assertNotFalse($task_id);
+
+ $this->assertFalse($this->app->updateTask(array('id' => $task_id, 'project_id' => $project_id, 'owner_id' => 1)));
+
+ $task = $this->app->getTask($task_id);
+ $this->assertEquals(0, $task['owner_id']);
+ }
+
+ public function testChangeAssigneeToNobody()
+ {
+ $project_id = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id);
+
+ $user_id = $this->app->createUser('user1', 'password');
+ $this->assertNotFalse($user_id);
+
+ $this->assertTrue($this->app->addProjectUser($project_id, $user_id, 'project-member'));
+
+ $task_id = $this->app->createTask(array('project_id' => $project_id, 'title' => 'My task', 'owner_id' => $user_id));
+ $this->assertNotFalse($task_id);
+
+ $this->assertTrue($this->app->updateTask(array('id' => $task_id, 'project_id' => $project_id, 'owner_id' => 0)));
+
+ $task = $this->app->getTask($task_id);
+ $this->assertEquals(0, $task['owner_id']);
+ }
+
+ public function testMoveTaskToAnotherProject()
+ {
+ $project_id1 = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id1);
+
+ $project_id2 = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id2);
+
+ $task_id = $this->app->createTask(array('project_id' => $project_id1, 'title' => 'My task'));
+ $this->assertNotFalse($task_id);
+
+ $this->assertTrue($this->app->moveTaskToProject($task_id, $project_id2));
+
+ $task = $this->app->getTask($task_id);
+ $this->assertEquals($project_id2, $task['project_id']);
+ }
+
+ public function testMoveCopyToAnotherProject()
+ {
+ $project_id1 = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id1);
+
+ $project_id2 = $this->app->createProject('My project');
+ $this->assertNotFalse($project_id2);
+
+ $task_id1 = $this->app->createTask(array('project_id' => $project_id1, 'title' => 'My task'));
+ $this->assertNotFalse($task_id1);
+
+ $task_id2 = $this->app->duplicateTaskToProject($task_id1, $project_id2);
+ $this->assertNotFalse($task_id2);
+
+ $task = $this->app->getTask($task_id1);
+ $this->assertEquals($project_id1, $task['project_id']);
+
+ $task = $this->app->getTask($task_id2);
+ $this->assertEquals($project_id2, $task['project_id']);
+ }
+}
diff --git a/tests/integration/UserTest.php b/tests/integration/UserTest.php
new file mode 100644
index 00000000..10da051c
--- /dev/null
+++ b/tests/integration/UserTest.php
@@ -0,0 +1,18 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+class UserTest extends Base
+{
+ public function testDisableUser()
+ {
+ $this->assertEquals(2, $this->app->createUser(array('username' => 'someone', 'password' => 'test123')));
+ $this->assertTrue($this->app->isActiveUser(2));
+
+ $this->assertTrue($this->app->disableUser(2));
+ $this->assertFalse($this->app->isActiveUser(2));
+
+ $this->assertTrue($this->app->enableUser(2));
+ $this->assertTrue($this->app->isActiveUser(2));
+ }
+}
diff --git a/tests/units/Action/BaseActionTest.php b/tests/units/Action/BaseActionTest.php
new file mode 100644
index 00000000..1d50c70e
--- /dev/null
+++ b/tests/units/Action/BaseActionTest.php
@@ -0,0 +1,144 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+
+class DummyAction extends Kanboard\Action\Base
+{
+ public function getDescription()
+ {
+ return 'Dummy Action';
+ }
+
+ public function getCompatibleEvents()
+ {
+ return array('my.event');
+ }
+
+ public function getActionRequiredParameters()
+ {
+ return array('p1' => 'Param 1');
+ }
+
+ public function getEventRequiredParameters()
+ {
+ return array('p1', 'p2');
+ }
+
+ public function doAction(array $data)
+ {
+ return true;
+ }
+
+ public function hasRequiredCondition(array $data)
+ {
+ return $data['p1'] == $this->getParam('p1');
+ }
+}
+
+class BaseActionTest extends Base
+{
+ public function testGetName()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertEquals('\\DummyAction', $dummyAction->getName());
+ }
+
+ public function testGetDescription()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertEquals('Dummy Action', $dummyAction->getDescription());
+ }
+
+ public function testGetActionRequiredParameters()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertEquals(array('p1' => 'Param 1'), $dummyAction->getActionRequiredParameters());
+ }
+
+ public function testGetEventRequiredParameters()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertEquals(array('p1', 'p2'), $dummyAction->getEventRequiredParameters());
+ }
+
+ public function testGetCompatibleEvents()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertEquals(array('my.event'), $dummyAction->getCompatibleEvents());
+ }
+
+ public function testHasRequiredCondition()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $dummyAction->setParam('p1', 123);
+ $this->assertTrue($dummyAction->hasRequiredCondition(array('p1' => 123)));
+ $this->assertFalse($dummyAction->hasRequiredCondition(array('p1' => 456)));
+ }
+
+ public function testProjectId()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertInstanceOf('DummyAction', $dummyAction->setProjectId(123));
+ $this->assertEquals(123, $dummyAction->getProjectId());
+ }
+
+ public function testParam()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertInstanceOf('DummyAction', $dummyAction->setParam('p1', 123));
+ $this->assertEquals(123, $dummyAction->getParam('p1'));
+ }
+
+ public function testHasCompatibleEvents()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $this->assertTrue($dummyAction->hasCompatibleEvent('my.event'));
+ $this->assertFalse($dummyAction->hasCompatibleEvent('foobar'));
+ }
+
+ public function testHasRequiredProject()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $dummyAction->setProjectId(1234);
+
+ $this->assertTrue($dummyAction->hasRequiredProject(array('project_id' => 1234)));
+ $this->assertFalse($dummyAction->hasRequiredProject(array('project_id' => 1)));
+ $this->assertFalse($dummyAction->hasRequiredProject(array()));
+ }
+
+ public function testHasRequiredParameters()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $dummyAction->setProjectId(1234);
+
+ $this->assertTrue($dummyAction->hasRequiredParameters(array('p1' => 12, 'p2' => 34)));
+ $this->assertFalse($dummyAction->hasRequiredParameters(array('p1' => 12)));
+ $this->assertFalse($dummyAction->hasRequiredParameters(array()));
+ }
+
+ public function testAddEvent()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $dummyAction->addEvent('foobar', 'FooBar');
+ $dummyAction->addEvent('my.event', 'My Event Overrided');
+
+ $events = $dummyAction->getEvents();
+ $this->assertcount(2, $events);
+ $this->assertEquals(array('my.event', 'foobar'), $events);
+ }
+
+ public function testExecuteOnlyOnce()
+ {
+ $dummyAction = new DummyAction($this->container);
+ $dummyAction->setProjectId(1234);
+ $dummyAction->setParam('p1', 'something');
+ $dummyAction->addEvent('foobar', 'FooBar');
+
+ $event = new GenericEvent(array('project_id' => 1234, 'p1' => 'something', 'p2' => 'abc'));
+
+ $this->assertTrue($dummyAction->execute($event, 'foobar'));
+ $this->assertFalse($dummyAction->execute($event, 'foobar'));
+ }
+}
diff --git a/tests/units/Action/CommentCreationMoveTaskColumnTest.php b/tests/units/Action/CommentCreationMoveTaskColumnTest.php
new file mode 100644
index 00000000..87ee86ea
--- /dev/null
+++ b/tests/units/Action/CommentCreationMoveTaskColumnTest.php
@@ -0,0 +1,58 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Comment;
+use Kanboard\Model\Project;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Action\CommentCreationMoveTaskColumn;
+
+class CommentCreationMoveTaskColumnTest extends Base
+{
+ public function testSuccess()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $commentModel = new Comment($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
+
+ $action = new CommentCreationMoveTaskColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_MOVE_COLUMN));
+
+ $comment = $commentModel->getById(1);
+ $this->assertNotEmpty($comment);
+ $this->assertEquals(1, $comment['task_id']);
+ $this->assertEquals(1, $comment['user_id']);
+ $this->assertEquals('Moved to column Ready', $comment['comment']);
+ }
+
+ public function testWithUserNotLogged()
+ {
+ $projectModel = new Project($this->container);
+ $commentModel = new Comment($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
+
+ $action = new CommentCreationMoveTaskColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
+ }
+}
diff --git a/tests/units/Action/CommentCreationTest.php b/tests/units/Action/CommentCreationTest.php
index 8a689309..8460a350 100644
--- a/tests/units/Action/CommentCreationTest.php
+++ b/tests/units/Action/CommentCreationTest.php
@@ -7,119 +7,88 @@ use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\Comment;
use Kanboard\Model\Project;
-use Kanboard\Integration\GithubWebhook;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\User;
use Kanboard\Action\CommentCreation;
+use Kanboard\Core\Security\Role;
class CommentCreationTest extends Base
{
- public function testWithoutRequiredParams()
+ public function testSuccess()
{
- $action = new CommentCreation($this->container, 1, GithubWebhook::EVENT_ISSUE_COMMENT);
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $c = new Comment($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'user_id' => 1,
- );
-
- // Our event should be executed
- $this->assertFalse($action->execute(new GenericEvent($event)));
-
- $comment = $c->getById(1);
- $this->assertEmpty($comment);
- }
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $commentModel = new Comment($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- public function testWithCommitMessage()
- {
- $action = new CommentCreation($this->container, 1, GithubWebhook::EVENT_ISSUE_COMMENT);
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $c = new Comment($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'commit_comment' => 'plop',
- );
-
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
-
- $comment = $c->getById(1);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'comment' => 'test123', 'reference' => 'ref123', 'user_id' => 2));
+
+ $action = new CommentCreation($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertTrue($action->execute($event, 'test.event'));
+
+ $comment = $commentModel->getById(1);
$this->assertNotEmpty($comment);
$this->assertEquals(1, $comment['task_id']);
- $this->assertEquals(0, $comment['user_id']);
- $this->assertEquals('plop', $comment['comment']);
+ $this->assertEquals('test123', $comment['comment']);
+ $this->assertEquals('ref123', $comment['reference']);
+ $this->assertEquals(2, $comment['user_id']);
}
- public function testWithUser()
+ public function testWithUserNotAssignable()
{
- $action = new CommentCreation($this->container, 1, GithubWebhook::EVENT_ISSUE_COMMENT);
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $c = new Comment($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'user_id' => 1,
- 'comment' => 'youpi',
- );
-
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
-
- $comment = $c->getById(1);
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $commentModel = new Comment($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'comment' => 'test123', 'user_id' => 2));
+
+ $action = new CommentCreation($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertTrue($action->execute($event, 'test.event'));
+
+ $comment = $commentModel->getById(1);
$this->assertNotEmpty($comment);
$this->assertEquals(1, $comment['task_id']);
- $this->assertEquals(1, $comment['user_id']);
- $this->assertEquals('youpi', $comment['comment']);
+ $this->assertEquals('test123', $comment['comment']);
+ $this->assertEquals('', $comment['reference']);
+ $this->assertEquals(0, $comment['user_id']);
}
- public function testWithNoUser()
+ public function testWithNoComment()
{
- $action = new CommentCreation($this->container, 1, GithubWebhook::EVENT_ISSUE_COMMENT);
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $c = new Comment($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'user_id' => 0,
- 'comment' => 'youpi',
- );
-
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
-
- $comment = $c->getById(1);
- $this->assertNotEmpty($comment);
- $this->assertEquals(1, $comment['task_id']);
- $this->assertEquals(0, $comment['user_id']);
- $this->assertEquals('youpi', $comment['comment']);
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $commentModel = new Comment($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1));
+
+ $action = new CommentCreation($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertFalse($action->execute($event, 'test.event'));
}
}
diff --git a/tests/units/Action/TaskAssignCategoryColorTest.php b/tests/units/Action/TaskAssignCategoryColorTest.php
new file mode 100644
index 00000000..bd8181e8
--- /dev/null
+++ b/tests/units/Action/TaskAssignCategoryColorTest.php
@@ -0,0 +1,60 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Category;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\Task;
+use Kanboard\Action\TaskAssignCategoryColor;
+
+class TaskAssignCategoryColorTest extends Base
+{
+ public function testChangeCategory()
+ {
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'color_id' => 'red'));
+
+ $action = new TaskAssignCategoryColor($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('category_id', 1);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_CREATE_UPDATE));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['category_id']);
+ }
+
+ public function testWithWrongColor()
+ {
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'color_id' => 'blue'));
+
+ $action = new TaskAssignCategoryColor($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('category_id', 1);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_CREATE_UPDATE));
+ }
+}
diff --git a/tests/units/Action/TaskAssignCategoryLabelTest.php b/tests/units/Action/TaskAssignCategoryLabelTest.php
new file mode 100644
index 00000000..bf8bdb5b
--- /dev/null
+++ b/tests/units/Action/TaskAssignCategoryLabelTest.php
@@ -0,0 +1,85 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Category;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Action\TaskAssignCategoryLabel;
+
+class TaskAssignCategoryLabelTest extends Base
+{
+ public function testChangeCategory()
+ {
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'label' => 'foobar'));
+
+ $action = new TaskAssignCategoryLabel($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+ $action->setParam('label', 'foobar');
+ $action->setParam('category_id', 1);
+
+ $this->assertTrue($action->execute($event, 'test.event'));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['category_id']);
+ }
+
+ public function testWithWrongLabel()
+ {
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'label' => 'something'));
+
+ $action = new TaskAssignCategoryLabel($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+ $action->setParam('label', 'foobar');
+ $action->setParam('category_id', 1);
+
+ $this->assertFalse($action->execute($event, 'test.event'));
+ }
+
+ public function testWithExistingCategory()
+ {
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+ $this->assertEquals(2, $categoryModel->create(array('name' => 'c2', 'project_id' => 1)));
+
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'category_id' => 2)));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'label' => 'foobar', 'category_id' => 2));
+
+ $action = new TaskAssignCategoryLabel($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+ $action->setParam('label', 'foobar');
+ $action->setParam('category_id', 1);
+
+ $this->assertFalse($action->execute($event, 'test.event'));
+ }
+}
diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php
new file mode 100644
index 00000000..f638e017
--- /dev/null
+++ b/tests/units/Action/TaskAssignCategoryLinkTest.php
@@ -0,0 +1,97 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\TaskLink;
+use Kanboard\Model\Category;
+use Kanboard\Event\TaskLinkEvent;
+use Kanboard\Action\TaskAssignCategoryLink;
+
+class TaskAssignCategoryLinkTest extends Base
+{
+ public function testAssignCategory()
+ {
+ $tc = new TaskCreation($this->container);
+ $tf = new TaskFinder($this->container);
+ $p = new Project($this->container);
+ $c = new Category($this->container);
+
+ $action = new TaskAssignCategoryLink($this->container);
+ $action->setProjectId(1);
+ $action->setParam('category_id', 1);
+ $action->setParam('link_id', 2);
+
+ $this->assertEquals(1, $p->create(array('name' => 'P1')));
+ $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1)));
+ $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1)));
+
+ $event = new TaskLinkEvent(array(
+ 'project_id' => 1,
+ 'task_id' => 1,
+ 'opposite_task_id' => 2,
+ 'link_id' => 2,
+ ));
+
+ $this->assertTrue($action->execute($event, TaskLink::EVENT_CREATE_UPDATE));
+
+ $task = $tf->getById(1);
+ $this->assertEquals(1, $task['category_id']);
+ }
+
+ public function testWhenLinkDontMatch()
+ {
+ $tc = new TaskCreation($this->container);
+ $tf = new TaskFinder($this->container);
+ $p = new Project($this->container);
+ $c = new Category($this->container);
+
+ $action = new TaskAssignCategoryLink($this->container);
+ $action->setProjectId(1);
+ $action->setParam('category_id', 1);
+ $action->setParam('link_id', 1);
+
+ $this->assertEquals(1, $p->create(array('name' => 'P1')));
+ $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1)));
+ $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1)));
+
+ $event = new TaskLinkEvent(array(
+ 'project_id' => 1,
+ 'task_id' => 1,
+ 'opposite_task_id' => 2,
+ 'link_id' => 2,
+ ));
+
+ $this->assertFalse($action->execute($event, TaskLink::EVENT_CREATE_UPDATE));
+ }
+
+ public function testThatExistingCategoryWillNotChange()
+ {
+ $tc = new TaskCreation($this->container);
+ $tf = new TaskFinder($this->container);
+ $p = new Project($this->container);
+ $c = new Category($this->container);
+
+ $action = new TaskAssignCategoryLink($this->container);
+ $action->setProjectId(1);
+ $action->setParam('category_id', 2);
+ $action->setParam('link_id', 2);
+
+ $this->assertEquals(1, $p->create(array('name' => 'P1')));
+ $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1)));
+ $this->assertEquals(2, $c->create(array('name' => 'C2', 'project_id' => 1)));
+ $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'category_id' => 1)));
+
+ $event = new TaskLinkEvent(array(
+ 'project_id' => 1,
+ 'task_id' => 1,
+ 'opposite_task_id' => 2,
+ 'link_id' => 2,
+ ));
+
+ $this->assertFalse($action->execute($event, TaskLink::EVENT_CREATE_UPDATE));
+ }
+}
diff --git a/tests/units/Action/TaskAssignColorCategoryTest.php b/tests/units/Action/TaskAssignColorCategoryTest.php
index 1bd3493b..9f188645 100644
--- a/tests/units/Action/TaskAssignColorCategoryTest.php
+++ b/tests/units/Action/TaskAssignColorCategoryTest.php
@@ -2,80 +2,58 @@
require_once __DIR__.'/../Base.php';
-use Kanboard\Model\Task;
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Category;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
-use Kanboard\Model\Category;
-use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Task;
use Kanboard\Action\TaskAssignColorCategory;
class TaskAssignColorCategoryTest extends Base
{
- public function testBadProject()
+ public function testChangeColor()
{
- $action = new TaskAssignColorCategory($this->container, 3, Task::EVENT_CREATE_UPDATE);
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'category_id' => 1));
- public function testExecute()
- {
- $action = new TaskAssignColorCategory($this->container, 1, Task::EVENT_CREATE_UPDATE);
+ $action = new TaskAssignColorCategory($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
$action->setParam('category_id', 1);
- $action->setParam('color_id', 'blue');
- // 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->assertTrue($action->execute($event, Task::EVENT_CREATE_UPDATE));
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $c->create(array('name' => 'c1', 'project_id' => 1)));
- $this->assertEquals(2, $c->create(array('name' => 'c2', 'project_id' => 1)));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1, 'color_id' => 'green', 'category_id' => 2)));
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('red', $task['color_id']);
+ }
- // We create an event but we don't do anything
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 1,
- 'category_id' => 2,
- 'position' => 2,
- );
+ public function testWithWrongCategory()
+ {
+ $categoryModel = new Category($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- // Our event should NOT be executed
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // Our task should be assigned to the ategory_id=1 and have the green color
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(2, $task['category_id']);
- $this->assertEquals('green', $task['color_id']);
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'category_id' => 2));
- // We create an event to move the task
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 1,
- 'position' => 5,
- 'category_id' => 1,
- );
-
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $action = new TaskAssignColorCategory($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('category_id', 1);
- // Our task should have the blue color
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals('blue', $task['color_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_CREATE_UPDATE));
}
}
diff --git a/tests/units/Action/TaskAssignColorColumnTest.php b/tests/units/Action/TaskAssignColorColumnTest.php
index c09dc96e..e5858b19 100644
--- a/tests/units/Action/TaskAssignColorColumnTest.php
+++ b/tests/units/Action/TaskAssignColorColumnTest.php
@@ -3,40 +3,53 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
-use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
+use Kanboard\Model\Task;
use Kanboard\Action\TaskAssignColorColumn;
class TaskAssignColorColumnTest extends Base
{
- public function testColorChange()
+ public function testChangeColumn()
{
- $action = new TaskAssignColorColumn($this->container, 1, Task::EVENT_MOVE_COLUMN);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
+
+ $action = new TaskAssignColorColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
$action->setParam('column_id', 2);
- $action->setParam('color_id', 'green');
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
+ $this->assertTrue($action->execute($event, Task::EVENT_MOVE_COLUMN));
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1, 'color_id' => 'yellow')));
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('red', $task['color_id']);
+ }
+
+ public function testWithWrongCategory()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- 'color_id' => 'green',
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
+
+ $action = new TaskAssignColorColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('column_id', 2);
- // Our task should have color green
- $task = $tf->getById(1);
- $this->assertEquals('green', $task['color_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
}
}
diff --git a/tests/units/Action/TaskAssignColorLinkTest.php b/tests/units/Action/TaskAssignColorLinkTest.php
index 36a831dd..d89c8b06 100644
--- a/tests/units/Action/TaskAssignColorLinkTest.php
+++ b/tests/units/Action/TaskAssignColorLinkTest.php
@@ -2,47 +2,54 @@
require_once __DIR__.'/../Base.php';
-use Kanboard\Event\TaskLinkEvent;
-use Kanboard\Model\Task;
+use Kanboard\Event\GenericEvent;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
-use Kanboard\Model\TaskLink;
use Kanboard\Model\Project;
+use Kanboard\Model\TaskLink;
use Kanboard\Action\TaskAssignColorLink;
class TaskAssignColorLinkTest extends Base
{
- public function testExecute()
+ public function testChangeColor()
{
- $action = new TaskAssignColorLink($this->container, 1, TaskLink::EVENT_CREATE_UPDATE);
- $action->setParam('link_id', 2);
- $action->setParam('color_id', 'green');
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $tl = new TaskLink($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1)));
-
- // The color should be yellow
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals('yellow', $task['color_id']);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'link_id' => 2,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // Our event should be executed
- $this->assertTrue($action->execute(new TaskLinkEvent($event)));
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'link_id' => 1));
- // The color should be green
- $task = $tf->getById(1);
+ $action = new TaskAssignColorLink($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('link_id', 1);
+
+ $this->assertTrue($action->execute($event, TaskLink::EVENT_CREATE_UPDATE));
+
+ $task = $taskFinderModel->getById(1);
$this->assertNotEmpty($task);
- $this->assertEquals('green', $task['color_id']);
+ $this->assertEquals('red', $task['color_id']);
+ }
+
+ public function testWithWrongLink()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'link_id' => 2));
+
+ $action = new TaskAssignColorLink($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('link_id', 1);
+
+ $this->assertFalse($action->execute($event, TaskLink::EVENT_CREATE_UPDATE));
}
}
diff --git a/tests/units/Action/TaskAssignColorUserTest.php b/tests/units/Action/TaskAssignColorUserTest.php
index ea2a8f38..e2656cc0 100644
--- a/tests/units/Action/TaskAssignColorUserTest.php
+++ b/tests/units/Action/TaskAssignColorUserTest.php
@@ -2,72 +2,54 @@
require_once __DIR__.'/../Base.php';
-use Kanboard\Model\Task;
+use Kanboard\Event\GenericEvent;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
-use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Task;
use Kanboard\Action\TaskAssignColorUser;
class TaskAssignColorUserTest extends Base
{
- public function testBadProject()
+ public function testChangeColor()
{
- $action = new TaskAssignColorUser($this->container, 3, Task::EVENT_CREATE);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'owner_id' => 1));
- public function testExecute()
- {
- $action = new TaskAssignColorUser($this->container, 1, Task::EVENT_ASSIGNEE_CHANGE);
+ $action = new TaskAssignColorUser($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
$action->setParam('user_id', 1);
- $action->setParam('color_id', 'blue');
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1, 'color_id' => 'green')));
+ $this->assertTrue($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
- // We change the assignee
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'owner_id' => 5,
- );
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('red', $task['color_id']);
+ }
- // Our event should NOT be executed
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ public function testWithWrongUser()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- // Our task should be assigned to nobody and have the green color
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(0, $task['owner_id']);
- $this->assertEquals('green', $task['color_id']);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // We change the assignee
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'owner_id' => 1,
- );
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'owner_id' => 2));
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $action = new TaskAssignColorUser($this->container);
+ $action->setProjectId(1);
+ $action->setParam('color_id', 'red');
+ $action->setParam('user_id', 1);
- // Our task should be assigned to nobody and have the blue color
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(0, $task['owner_id']);
- $this->assertEquals('blue', $task['color_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
}
}
diff --git a/tests/units/Action/TaskAssignCurrentUserColumnTest.php b/tests/units/Action/TaskAssignCurrentUserColumnTest.php
new file mode 100644
index 00000000..41576ee4
--- /dev/null
+++ b/tests/units/Action/TaskAssignCurrentUserColumnTest.php
@@ -0,0 +1,75 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\Task;
+use Kanboard\Action\TaskAssignCurrentUserColumn;
+
+class TaskAssignCurrentUserColumnTest extends Base
+{
+ public function testChangeUser()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
+
+ $action = new TaskAssignCurrentUserColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_MOVE_COLUMN));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['owner_id']);
+ }
+
+ public function testWithWrongColumn()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
+
+ $action = new TaskAssignCurrentUserColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
+ }
+
+ public function testWithNoUserSession()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
+
+ $action = new TaskAssignCurrentUserColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
+ }
+}
diff --git a/tests/units/Action/TaskAssignCurrentUserTest.php b/tests/units/Action/TaskAssignCurrentUserTest.php
index f8946577..2fe84a5d 100644
--- a/tests/units/Action/TaskAssignCurrentUserTest.php
+++ b/tests/units/Action/TaskAssignCurrentUserTest.php
@@ -3,76 +3,51 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
-use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
-use Kanboard\Model\UserSession;
+use Kanboard\Model\Task;
use Kanboard\Action\TaskAssignCurrentUser;
class TaskAssignCurrentUserTest extends Base
{
- public function testBadProject()
+ public function testChangeUser()
{
- $action = new TaskAssignCurrentUser($this->container, 3, Task::EVENT_CREATE);
- $action->setParam('column_id', 5);
+ $this->container['sessionStorage']->user = array('id' => 1);
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- public function testBadColumn()
- {
- $action = new TaskAssignCurrentUser($this->container, 3, Task::EVENT_CREATE);
- $action->setParam('column_id', 5);
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1));
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 3,
- );
+ $action = new TaskAssignCurrentUser($this->container);
+ $action->setProjectId(1);
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $this->assertTrue($action->execute($event, Task::EVENT_CREATE));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['owner_id']);
}
- public function testExecute()
+ public function testWithNoUserSession()
{
- $action = new TaskAssignCurrentUser($this->container, 1, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 2);
- $_SESSION = array(
- 'user' => array('id' => 5)
- );
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $us = new UserSession($this->container);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertEquals(5, $us->getId());
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1));
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ $action = new TaskAssignCurrentUser($this->container);
+ $action->setProjectId(1);
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
-
- // Our task should be assigned to the user 5 (from the session)
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['id']);
- $this->assertEquals(5, $task['owner_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_CREATE));
}
}
diff --git a/tests/units/Action/TaskAssignSpecificUserTest.php b/tests/units/Action/TaskAssignSpecificUserTest.php
index a67335e8..67b2c397 100644
--- a/tests/units/Action/TaskAssignSpecificUserTest.php
+++ b/tests/units/Action/TaskAssignSpecificUserTest.php
@@ -3,69 +3,53 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
-use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
+use Kanboard\Model\Task;
use Kanboard\Action\TaskAssignSpecificUser;
class TaskAssignSpecificUserTest extends Base
{
- public function testBadProject()
+ public function testChangeUser()
{
- $action = new TaskAssignSpecificUser($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'owner_id' => 0)));
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
- public function testBadColumn()
- {
- $action = new TaskAssignSpecificUser($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $action = new TaskAssignSpecificUser($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+ $action->setParam('user_id', 1);
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 3,
- );
+ $this->assertTrue($action->execute($event, Task::EVENT_MOVE_COLUMN));
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['owner_id']);
}
- public function testExecute()
+ public function testWithWrongColumn()
{
- $action = new TaskAssignSpecificUser($this->container, 1, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 2);
- $action->setParam('user_id', 1);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $action = new TaskAssignSpecificUser($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+ $action->setParam('user_id', 1);
- // Our task should be assigned to the user 1
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['owner_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
}
}
diff --git a/tests/units/Action/TaskAssignUserTest.php b/tests/units/Action/TaskAssignUserTest.php
new file mode 100644
index 00000000..d1cb72b9
--- /dev/null
+++ b/tests/units/Action/TaskAssignUserTest.php
@@ -0,0 +1,62 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\User;
+use Kanboard\Model\Task;
+use Kanboard\Action\TaskAssignUser;
+use Kanboard\Core\Security\Role;
+
+class TaskAssignUserTest extends Base
+{
+ public function testChangeUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'owner_id' => 0)));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'owner_id' => 2));
+
+ $action = new TaskAssignUser($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertTrue($action->execute($event, 'test.event'));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(2, $task['owner_id']);
+ }
+
+ public function testWithNotAssignableUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'owner_id' => 1));
+
+ $action = new TaskAssignUser($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertFalse($action->execute($event, 'test.event'));
+ }
+}
diff --git a/tests/units/Action/TaskCloseColumnTest.php b/tests/units/Action/TaskCloseColumnTest.php
new file mode 100644
index 00000000..ce41bb41
--- /dev/null
+++ b/tests/units/Action/TaskCloseColumnTest.php
@@ -0,0 +1,53 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\Task;
+use Kanboard\Action\TaskCloseColumn;
+
+class TaskCloseColumnTest extends Base
+{
+ public function testClose()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
+
+ $action = new TaskCloseColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_MOVE_COLUMN));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(0, $task['is_active']);
+ }
+
+ public function testWithWrongColumn()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
+
+ $action = new TaskCloseColumn($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
+ }
+}
diff --git a/tests/units/Action/TaskCloseNoActivityTest.php b/tests/units/Action/TaskCloseNoActivityTest.php
new file mode 100644
index 00000000..b6e04c47
--- /dev/null
+++ b/tests/units/Action/TaskCloseNoActivityTest.php
@@ -0,0 +1,43 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\TaskListEvent;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\Task;
+use Kanboard\Action\TaskCloseNoActivity;
+
+class TaskCloseNoActivityTest extends Base
+{
+ public function testClose()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->container['db']->table(Task::TABLE)->eq('id', 1)->update(array('date_modification' => strtotime('-10days')));
+
+ $tasks = $taskFinderModel->getAll(1);
+ $event = new TaskListEvent(array('tasks' => $tasks, 'project_id' => 1));
+
+ $action = new TaskCloseNoActivity($this->container);
+ $action->setProjectId(1);
+ $action->setParam('duration', 2);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_DAILY_CRONJOB));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(0, $task['is_active']);
+
+ $task = $taskFinderModel->getById(2);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['is_active']);
+ }
+}
diff --git a/tests/units/Action/TaskCloseTest.php b/tests/units/Action/TaskCloseTest.php
index b2d83194..536d79ca 100644
--- a/tests/units/Action/TaskCloseTest.php
+++ b/tests/units/Action/TaskCloseTest.php
@@ -3,107 +3,49 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
-use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
-use Kanboard\Integration\GithubWebhook;
use Kanboard\Action\TaskClose;
class TaskCloseTest extends Base
{
- public function testExecutable()
+ public function testClose()
{
- $action = new TaskClose($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertTrue($action->isExecutable($event));
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1));
- $action = new TaskClose($this->container, 3, GithubWebhook::EVENT_COMMIT);
+ $action = new TaskClose($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- );
+ $this->assertTrue($action->execute($event, 'test.event'));
- $this->assertTrue($action->isExecutable($event));
- }
-
- public function testBadEvent()
- {
- $action = new TaskClose($this->container, 3, Task::EVENT_UPDATE);
- $action->setParam('column_id', 5);
-
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 5,
- );
-
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
-
- public function testBadProject()
- {
- $action = new TaskClose($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
-
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
-
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
-
- public function testBadColumn()
- {
- $action = new TaskClose($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
-
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 3,
- );
-
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(0, $task['is_active']);
}
- public function testExecute()
+ public function testWithNoTaskId()
{
- $action = new TaskClose($this->container, 1, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 2);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ $event = new GenericEvent(array('project_id' => 1));
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $action = new TaskClose($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
- // Our task should be closed
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(0, $task['is_active']);
+ $this->assertFalse($action->execute($event, 'test.event'));
}
}
diff --git a/tests/units/Action/TaskCreationTest.php b/tests/units/Action/TaskCreationTest.php
new file mode 100644
index 00000000..57c2995d
--- /dev/null
+++ b/tests/units/Action/TaskCreationTest.php
@@ -0,0 +1,49 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Action\TaskCreation as TaskCreationAction;
+
+class TaskCreationActionTest extends Base
+{
+ public function testSuccess()
+ {
+ $projectModel = new Project($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'title' => 'test123', 'reference' => 'ref123', 'description' => 'test'));
+
+ $action = new TaskCreationAction($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertTrue($action->execute($event, 'test.event'));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('test123', $task['title']);
+ $this->assertEquals('ref123', $task['reference']);
+ $this->assertEquals('test', $task['description']);
+ }
+
+ public function testWithNoTitle()
+ {
+ $projectModel = new Project($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'reference' => 'ref123', 'description' => 'test'));
+
+ $action = new TaskCreationAction($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertFalse($action->execute($event, 'test.event'));
+ }
+}
diff --git a/tests/units/Action/TaskDuplicateAnotherProjectTest.php b/tests/units/Action/TaskDuplicateAnotherProjectTest.php
index 50cbad01..d9491dd9 100644
--- a/tests/units/Action/TaskDuplicateAnotherProjectTest.php
+++ b/tests/units/Action/TaskDuplicateAnotherProjectTest.php
@@ -4,92 +4,53 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
use Kanboard\Model\Task;
-use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
+use Kanboard\Model\TaskCreation;
use Kanboard\Model\Project;
use Kanboard\Action\TaskDuplicateAnotherProject;
class TaskDuplicateAnotherProjectTest extends Base
{
- public function testBadProject()
+ public function testSuccess()
{
- $action = new TaskDuplicateAnotherProject($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
- public function testBadColumn()
- {
- $action = new TaskDuplicateAnotherProject($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $action = new TaskDuplicateAnotherProject($this->container);
+ $action->setProjectId(1);
+ $action->setParam('project_id', 2);
+ $action->setParam('column_id', 2);
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 3,
- );
+ $this->assertTrue($action->execute($event, Task::EVENT_CLOSE));
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $task = $taskFinderModel->getById(2);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('test', $task['title']);
+ $this->assertEquals(2, $task['project_id']);
}
- public function testExecute()
+ public function testWithWrongColumn()
{
- $action = new TaskDuplicateAnotherProject($this->container, 1, Task::EVENT_MOVE_COLUMN);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'project 1')));
- $this->assertEquals(2, $p->create(array('name' => 'project 2')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
-
- // Our event should NOT be executed because we define the same project
- $action->setParam('column_id', 2);
- $action->setParam('project_id', 1);
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
- // Our task should be assigned to the project 1
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['project_id']);
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
-
- // Our event should be executed because we define a different project
- $action->setParam('column_id', 2);
+ $action = new TaskDuplicateAnotherProject($this->container);
+ $action->setProjectId(1);
$action->setParam('project_id', 2);
- $this->assertTrue($action->hasRequiredCondition($event));
- $this->assertTrue($action->execute(new GenericEvent($event)));
-
- // Our task should be assigned to the project 1
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['project_id']);
+ $action->setParam('column_id', 2);
- // We should have another task assigned to the project 2
- $task = $tf->getById(2);
- $this->assertNotEmpty($task);
- $this->assertEquals(2, $task['project_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_CLOSE));
}
}
diff --git a/tests/units/Action/TaskEmailNoActivityTest.php b/tests/units/Action/TaskEmailNoActivityTest.php
new file mode 100644
index 00000000..af4baed5
--- /dev/null
+++ b/tests/units/Action/TaskEmailNoActivityTest.php
@@ -0,0 +1,103 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\TaskListEvent;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Model\Task;
+use Kanboard\Model\User;
+use Kanboard\Action\TaskEmailNoActivity;
+
+class TaskEmailNoActivityTest extends Base
+{
+ public function testSendEmail()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'test', 'email' => 'chuck@norris', 'name' => 'Chuck Norris')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->container['db']->table(Task::TABLE)->eq('id', 1)->update(array('date_modification' => strtotime('-10days')));
+
+ $tasks = $taskFinderModel->getAll(1);
+ $event = new TaskListEvent(array('tasks' => $tasks, 'project_id' => 1));
+
+ $action = new TaskEmailNoActivity($this->container);
+ $action->setProjectId(1);
+ $action->setParam('user_id', 2);
+ $action->setParam('subject', 'Old tasks');
+ $action->setParam('duration', 2);
+
+ $this->container['emailClient']
+ ->expects($this->once())
+ ->method('send')
+ ->with('chuck@norris', 'Chuck Norris', 'Old tasks', $this->anything());
+
+ $this->assertTrue($action->execute($event, Task::EVENT_DAILY_CRONJOB));
+ }
+
+ public function testUserWithNoEmail()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'test', 'name' => 'Chuck Norris')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->container['db']->table(Task::TABLE)->eq('id', 1)->update(array('date_modification' => strtotime('-10days')));
+
+ $tasks = $taskFinderModel->getAll(1);
+ $event = new TaskListEvent(array('tasks' => $tasks, 'project_id' => 1));
+
+ $action = new TaskEmailNoActivity($this->container);
+ $action->setProjectId(1);
+ $action->setParam('user_id', 2);
+ $action->setParam('subject', 'Old tasks');
+ $action->setParam('duration', 2);
+
+ $this->container['emailClient']
+ ->expects($this->never())
+ ->method('send');
+
+ $this->assertFalse($action->execute($event, Task::EVENT_DAILY_CRONJOB));
+ }
+
+ public function testTooRecent()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'test', 'email' => 'chuck@norris', 'name' => 'Chuck Norris')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $tasks = $taskFinderModel->getAll(1);
+ $event = new TaskListEvent(array('tasks' => $tasks, 'project_id' => 1));
+
+ $action = new TaskEmailNoActivity($this->container);
+ $action->setProjectId(1);
+ $action->setParam('user_id', 2);
+ $action->setParam('subject', 'Old tasks');
+ $action->setParam('duration', 2);
+
+ $this->container['emailClient']
+ ->expects($this->never())
+ ->method('send');
+
+ $this->assertFalse($action->execute($event, Task::EVENT_DAILY_CRONJOB));
+ }
+}
diff --git a/tests/units/Action/TaskEmailTest.php b/tests/units/Action/TaskEmailTest.php
index 404865f4..ef32a296 100644
--- a/tests/units/Action/TaskEmailTest.php
+++ b/tests/units/Action/TaskEmailTest.php
@@ -5,98 +5,30 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
-use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
use Kanboard\Model\User;
use Kanboard\Action\TaskEmail;
class TaskEmailTest extends Base
{
- public function testNoEmail()
+ public function testSuccess()
{
- $action = new TaskEmail($this->container, 1, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 2);
- $action->setParam('user_id', 1);
- $action->setParam('subject', 'My email subject');
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $u = new User($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertTrue($userModel->update(array('id' => 1, 'email' => 'admin@localhost')));
- // Email should be not be sent
- $this->container['emailClient']->expects($this->never())->method('send');
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
- // Our event should be executed
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
-
- public function testWrongColumn()
- {
- $action = new TaskEmail($this->container, 1, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 2);
- $action->setParam('user_id', 1);
- $action->setParam('subject', 'My email subject');
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $u = new User($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 3,
- );
-
- // Email should be not be sent
- $this->container['emailClient']->expects($this->never())->method('send');
-
- // Our event should be executed
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
-
- public function testMoveColumn()
- {
- $action = new TaskEmail($this->container, 1, Task::EVENT_MOVE_COLUMN);
+ $action = new TaskEmail($this->container);
+ $action->setProjectId(1);
$action->setParam('column_id', 2);
$action->setParam('user_id', 1);
$action->setParam('subject', 'My email subject');
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $u = new User($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
- $this->assertTrue($u->update(array('id' => 1, 'email' => 'admin@localhost')));
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
-
- // Email should be sent
$this->container['emailClient']->expects($this->once())
->method('send')
->with(
@@ -106,45 +38,24 @@ class TaskEmailTest extends Base
$this->stringContains('test')
);
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $this->assertTrue($action->execute($event, Task::EVENT_CLOSE));
}
- public function testTaskClose()
+ public function testWithWrongColumn()
{
- $action = new TaskEmail($this->container, 1, Task::EVENT_CLOSE);
- $action->setParam('column_id', 2);
- $action->setParam('user_id', 1);
- $action->setParam('subject', 'My email subject');
-
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $u = new User($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
- $this->assertTrue($u->update(array('id' => 1, 'email' => 'admin@localhost')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
- // We create an event
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
- // Email should be sent
- $this->container['emailClient']->expects($this->once())
- ->method('send')
- ->with(
- $this->equalTo('admin@localhost'),
- $this->equalTo('admin'),
- $this->equalTo('My email subject'),
- $this->stringContains('test')
- );
+ $action = new TaskEmail($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+ $action->setParam('user_id', 1);
+ $action->setParam('subject', 'My email subject');
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $this->assertFalse($action->execute($event, Task::EVENT_CLOSE));
}
}
diff --git a/tests/units/Action/TaskMoveAnotherProjectTest.php b/tests/units/Action/TaskMoveAnotherProjectTest.php
index 93ee3b94..dfabe5f8 100644
--- a/tests/units/Action/TaskMoveAnotherProjectTest.php
+++ b/tests/units/Action/TaskMoveAnotherProjectTest.php
@@ -4,86 +4,54 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
use Kanboard\Model\Task;
-use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
+use Kanboard\Model\TaskCreation;
use Kanboard\Model\Project;
use Kanboard\Action\TaskMoveAnotherProject;
class TaskMoveAnotherProjectTest extends Base
{
- public function testBadProject()
+ public function testSuccess()
{
- $action = new TaskMoveAnotherProject($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
- $event = array(
- 'project_id' => 2,
- 'task_id' => 3,
- 'column_id' => 5,
- );
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertFalse($action->isExecutable($event));
- $this->assertFalse($action->execute(new GenericEvent($event)));
- }
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
- public function testBadColumn()
- {
- $action = new TaskMoveAnotherProject($this->container, 3, Task::EVENT_MOVE_COLUMN);
- $action->setParam('column_id', 5);
+ $action = new TaskMoveAnotherProject($this->container);
+ $action->setProjectId(1);
+ $action->setParam('project_id', 2);
+ $action->setParam('column_id', 2);
- $event = array(
- 'project_id' => 3,
- 'task_id' => 3,
- 'column_id' => 3,
- );
+ $this->assertTrue($action->execute($event, Task::EVENT_CLOSE));
- $this->assertFalse($action->execute(new GenericEvent($event)));
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('test', $task['title']);
+ $this->assertEquals(2, $task['project_id']);
+ $this->assertEquals(5, $task['column_id']);
}
- public function testExecute()
+ public function testWithWrongColumn()
{
- $action = new TaskMoveAnotherProject($this->container, 1, Task::EVENT_MOVE_COLUMN);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'project 1')));
- $this->assertEquals(2, $p->create(array('name' => 'project 2')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
- // Our event should NOT be executed because we define the same project
- $action->setParam('column_id', 2);
- $action->setParam('project_id', 1);
- $this->assertFalse($action->execute(new GenericEvent($event)));
-
- // Our task should be assigned to the project 1
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['project_id']);
-
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
-
- // Our event should be executed because we define a different project
- $action->setParam('column_id', 2);
+ $action = new TaskMoveAnotherProject($this->container);
+ $action->setProjectId(1);
$action->setParam('project_id', 2);
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $action->setParam('column_id', 2);
- // Our task should be assigned to the project 2
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(2, $task['project_id']);
+ $this->assertFalse($action->execute($event, Task::EVENT_CLOSE));
}
}
diff --git a/tests/units/Action/TaskMoveColumnAssignedTest.php b/tests/units/Action/TaskMoveColumnAssignedTest.php
new file mode 100644
index 00000000..f0eec894
--- /dev/null
+++ b/tests/units/Action/TaskMoveColumnAssignedTest.php
@@ -0,0 +1,56 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Action\TaskMoveColumnAssigned;
+
+class TaskMoveColumnAssignedTest extends Base
+{
+ public function testSuccess()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'owner_id' => 1));
+
+ $action = new TaskMoveColumnAssigned($this->container);
+ $action->setProjectId(1);
+ $action->setParam('src_column_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('test', $task['title']);
+ $this->assertEquals(2, $task['column_id']);
+ }
+
+ public function testWithWrongColumn()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3, 'owner_id' => 1));
+
+ $action = new TaskMoveColumnAssigned($this->container);
+ $action->setProjectId(1);
+ $action->setParam('src_column_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
+ }
+}
diff --git a/tests/units/Action/TaskMoveColumnCategoryChangeTest.php b/tests/units/Action/TaskMoveColumnCategoryChangeTest.php
index 03423776..1f0768c1 100644
--- a/tests/units/Action/TaskMoveColumnCategoryChangeTest.php
+++ b/tests/units/Action/TaskMoveColumnCategoryChangeTest.php
@@ -3,60 +3,84 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Category;
use Kanboard\Model\Task;
-use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
+use Kanboard\Model\TaskCreation;
use Kanboard\Model\Project;
-use Kanboard\Model\Category;
-use Kanboard\Integration\GithubWebhook;
use Kanboard\Action\TaskMoveColumnCategoryChange;
class TaskMoveColumnCategoryChangeTest extends Base
{
- public function testExecute()
+ public function testSuccess()
{
- $action = new TaskMoveColumnCategoryChange($this->container, 1, Task::EVENT_UPDATE);
- $action->setParam('dest_column_id', 3);
- $action->setParam('category_id', 1);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+ $categoryModel = new Category($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertEquals(3, $action->getParam('dest_column_id'));
- $this->assertEquals(1, $action->getParam('category_id'));
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'category_id' => 1));
- // 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);
+ $action = new TaskMoveColumnCategoryChange($this->container);
+ $action->setProjectId(1);
+ $action->setParam('category_id', 1);
+ $action->setParam('dest_column_id', 2);
- $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)));
+ $this->assertTrue($action->execute($event, Task::EVENT_UPDATE));
- // 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);
+ $task = $taskFinderModel->getById(1);
$this->assertNotEmpty($task);
- $this->assertEquals(3, $task['column_id']);
+ $this->assertEquals('test', $task['title']);
+ $this->assertEquals(2, $task['column_id']);
+ }
+
+ public function testWithWrongColumn()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+ $categoryModel = new Category($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2, 'category_id' => 1));
+
+ $action = new TaskMoveColumnCategoryChange($this->container);
+ $action->setProjectId(1);
+ $action->setParam('category_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_UPDATE));
+ }
+
+ public function testWithWrongCategory()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+ $categoryModel = new Category($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+ $this->assertEquals(2, $categoryModel->create(array('name' => 'c2', 'project_id' => 1)));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'category_id' => 2));
+
+ $action = new TaskMoveColumnCategoryChange($this->container);
+ $action->setProjectId(1);
+ $action->setParam('category_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_UPDATE));
}
}
diff --git a/tests/units/Action/TaskMoveColumnUnAssignedTest.php b/tests/units/Action/TaskMoveColumnUnAssignedTest.php
new file mode 100644
index 00000000..0b54b781
--- /dev/null
+++ b/tests/units/Action/TaskMoveColumnUnAssignedTest.php
@@ -0,0 +1,74 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Action\TaskMoveColumnUnAssigned;
+
+class TaskMoveColumnUnAssignedTest extends Base
+{
+ public function testSuccess()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'owner_id' => 0));
+
+ $action = new TaskMoveColumnUnAssigned($this->container);
+ $action->setProjectId(1);
+ $action->setParam('src_column_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertTrue($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals('test', $task['title']);
+ $this->assertEquals(2, $task['column_id']);
+ }
+
+ public function testWithWrongColumn()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2, 'owner_id' => 0));
+
+ $action = new TaskMoveColumnUnAssigned($this->container);
+ $action->setProjectId(1);
+ $action->setParam('src_column_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
+ }
+
+ public function testWithWrongUser()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'owner_id' => 1));
+
+ $action = new TaskMoveColumnUnAssigned($this->container);
+ $action->setProjectId(1);
+ $action->setParam('src_column_id', 1);
+ $action->setParam('dest_column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_ASSIGNEE_CHANGE));
+ }
+}
diff --git a/tests/units/Action/TaskOpenTest.php b/tests/units/Action/TaskOpenTest.php
new file mode 100644
index 00000000..01290a64
--- /dev/null
+++ b/tests/units/Action/TaskOpenTest.php
@@ -0,0 +1,51 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Model\Project;
+use Kanboard\Action\TaskOpen;
+
+class TaskOpenTest extends Base
+{
+ public function testClose()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'is_active' => 0)));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1));
+
+ $action = new TaskOpen($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertTrue($action->execute($event, 'test.event'));
+
+ $task = $taskFinderModel->getById(1);
+ $this->assertNotEmpty($task);
+ $this->assertEquals(1, $task['is_active']);
+ }
+
+ public function testWithNoTaskId()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1));
+
+ $action = new TaskOpen($this->container);
+ $action->setProjectId(1);
+ $action->addEvent('test.event', 'Test Event');
+
+ $this->assertFalse($action->execute($event, 'test.event'));
+ }
+}
diff --git a/tests/units/Action/TaskUpdateStartDateTest.php b/tests/units/Action/TaskUpdateStartDateTest.php
index 7d558e28..adf5bd9d 100644
--- a/tests/units/Action/TaskUpdateStartDateTest.php
+++ b/tests/units/Action/TaskUpdateStartDateTest.php
@@ -3,44 +3,50 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Event\GenericEvent;
-use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
+use Kanboard\Model\Task;
use Kanboard\Action\TaskUpdateStartDate;
class TaskUpdateStartDateTest extends Base
{
- public function testExecute()
+ public function testClose()
{
- $action = new TaskUpdateStartDate($this->container, 1, Task::EVENT_MOVE_COLUMN);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2));
+
+ $action = new TaskUpdateStartDate($this->container);
+ $action->setProjectId(1);
$action->setParam('column_id', 2);
- // We create a task in the first column
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1)));
+ $this->assertTrue($action->execute($event, Task::EVENT_MOVE_COLUMN));
- // The start date must be empty
- $task = $tf->getById(1);
+ $task = $taskFinderModel->getById(1);
$this->assertNotEmpty($task);
- $this->assertEmpty($task['date_started']);
+ $this->assertEquals(time(), $task['date_started'], 'Date started delta', 2);
+ }
- // We create an event to move the task to the 2nd column
- $event = array(
- 'project_id' => 1,
- 'task_id' => 1,
- 'column_id' => 2,
- );
+ public function testWithWrongColumn()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
- // Our event should be executed
- $this->assertTrue($action->execute(new GenericEvent($event)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
- // Our task should be updated
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(time(), $task['date_started'], '', 2);
+ $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3));
+
+ $action = new TaskUpdateStartDate($this->container);
+ $action->setProjectId(1);
+ $action->setParam('column_id', 2);
+
+ $this->assertFalse($action->execute($event, Task::EVENT_MOVE_COLUMN));
}
}
diff --git a/tests/units/Analytic/AverageLeadCycleTimeAnalyticTest.php b/tests/units/Analytic/AverageLeadCycleTimeAnalyticTest.php
new file mode 100644
index 00000000..b8faec6c
--- /dev/null
+++ b/tests/units/Analytic/AverageLeadCycleTimeAnalyticTest.php
@@ -0,0 +1,70 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Model\Task;
+use Kanboard\Analytic\AverageLeadCycleTimeAnalytic;
+
+class AverageLeadCycleTimeAnalyticTest extends Base
+{
+ public function testBuild()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $averageLeadCycleTimeAnalytic = new AverageLeadCycleTimeAnalytic($this->container);
+ $now = time();
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(4, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ // LT=3600 CT=1800
+ $this->container['db']->table(Task::TABLE)->eq('id', 1)->update(array('date_completed' => $now + 3600, 'date_started' => $now + 1800));
+
+ // LT=1800 CT=900
+ $this->container['db']->table(Task::TABLE)->eq('id', 2)->update(array('date_completed' => $now + 1800, 'date_started' => $now + 900));
+
+ // LT=3600 CT=0
+ $this->container['db']->table(Task::TABLE)->eq('id', 3)->update(array('date_completed' => $now + 3600));
+
+ // LT=2*3600 CT=0
+ $this->container['db']->table(Task::TABLE)->eq('id', 4)->update(array('date_completed' => $now + 2 * 3600));
+
+ $stats = $averageLeadCycleTimeAnalytic->build(1);
+
+ $this->assertEquals(4, $stats['count']);
+ $this->assertEquals(3600 + 1800 + 3600 + 2*3600, $stats['total_lead_time'], '', 5);
+ $this->assertEquals(1800 + 900, $stats['total_cycle_time'], '', 5);
+ $this->assertEquals((3600 + 1800 + 3600 + 2*3600) / 4, $stats['avg_lead_time'], '', 5);
+ $this->assertEquals((1800 + 900) / 4, $stats['avg_cycle_time'], '', 5);
+ }
+
+ public function testBuildWithNoTasks()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $averageLeadCycleTimeAnalytic = new AverageLeadCycleTimeAnalytic($this->container);
+ $now = time();
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $stats = $averageLeadCycleTimeAnalytic->build(1);
+
+ $expected = array(
+ 'count' => 0,
+ 'total_lead_time' => 0,
+ 'total_cycle_time' => 0,
+ 'avg_lead_time' => 0,
+ 'avg_cycle_time' => 0,
+ );
+
+ $this->assertEquals($expected, $stats);
+ }
+}
diff --git a/tests/units/Analytic/AverageTimeSpentColumnAnalyticTest.php b/tests/units/Analytic/AverageTimeSpentColumnAnalyticTest.php
new file mode 100644
index 00000000..4e01bfa9
--- /dev/null
+++ b/tests/units/Analytic/AverageTimeSpentColumnAnalyticTest.php
@@ -0,0 +1,101 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Model\Transition;
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskFinder;
+use Kanboard\Analytic\AverageTimeSpentColumnAnalytic;
+
+class AverageTimeSpentColumnAnalyticTest extends Base
+{
+ public function testAverageWithNoTransitions()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $averageLeadCycleTimeAnalytic = new AverageTimeSpentColumnAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $now = time();
+
+ $this->container['db']->table(Task::TABLE)->eq('id', 1)->update(array('date_completed' => $now + 3600));
+ $this->container['db']->table(Task::TABLE)->eq('id', 2)->update(array('date_completed' => $now + 1800));
+
+ $stats = $averageLeadCycleTimeAnalytic->build(1);
+
+ $this->assertEquals(2, $stats[1]['count']);
+ $this->assertEquals(3600+1800, $stats[1]['time_spent'], '', 3);
+ $this->assertEquals((int) ((3600+1800)/2), $stats[1]['average'], '', 3);
+ $this->assertEquals('Backlog', $stats[1]['title']);
+
+ $this->assertEquals(0, $stats[2]['count']);
+ $this->assertEquals(0, $stats[2]['time_spent'], '', 3);
+ $this->assertEquals(0, $stats[2]['average'], '', 3);
+ $this->assertEquals('Ready', $stats[2]['title']);
+
+ $this->assertEquals(0, $stats[3]['count']);
+ $this->assertEquals(0, $stats[3]['time_spent'], '', 3);
+ $this->assertEquals(0, $stats[3]['average'], '', 3);
+ $this->assertEquals('Work in progress', $stats[3]['title']);
+
+ $this->assertEquals(0, $stats[4]['count']);
+ $this->assertEquals(0, $stats[4]['time_spent'], '', 3);
+ $this->assertEquals(0, $stats[4]['average'], '', 3);
+ $this->assertEquals('Done', $stats[4]['title']);
+ }
+
+ public function testAverageWithTransitions()
+ {
+ $transitionModel = new Transition($this->container);
+ $taskFinderModel = new TaskFinder($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $averageLeadCycleTimeAnalytic = new AverageTimeSpentColumnAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $now = time();
+ $this->container['db']->table(Task::TABLE)->eq('id', 1)->update(array('date_completed' => $now + 3600));
+ $this->container['db']->table(Task::TABLE)->eq('id', 2)->update(array('date_completed' => $now + 1800));
+
+ foreach (array(1, 2) as $task_id) {
+ $task = $taskFinderModel->getById($task_id);
+ $task['task_id'] = $task['id'];
+ $task['date_moved'] = $now - 900;
+ $task['src_column_id'] = 3;
+ $task['dst_column_id'] = 1;
+ $this->assertTrue($transitionModel->save(1, $task));
+ }
+
+ $stats = $averageLeadCycleTimeAnalytic->build(1);
+
+ $this->assertEquals(2, $stats[1]['count']);
+ $this->assertEquals(3600+1800, $stats[1]['time_spent'], '', 3);
+ $this->assertEquals((int) ((3600+1800)/2), $stats[1]['average'], '', 3);
+ $this->assertEquals('Backlog', $stats[1]['title']);
+
+ $this->assertEquals(0, $stats[2]['count']);
+ $this->assertEquals(0, $stats[2]['time_spent'], '', 3);
+ $this->assertEquals(0, $stats[2]['average'], '', 3);
+ $this->assertEquals('Ready', $stats[2]['title']);
+
+ $this->assertEquals(2, $stats[3]['count']);
+ $this->assertEquals(1800, $stats[3]['time_spent'], '', 3);
+ $this->assertEquals(900, $stats[3]['average'], '', 3);
+ $this->assertEquals('Work in progress', $stats[3]['title']);
+
+ $this->assertEquals(0, $stats[4]['count']);
+ $this->assertEquals(0, $stats[4]['time_spent'], '', 3);
+ $this->assertEquals(0, $stats[4]['average'], '', 3);
+ $this->assertEquals('Done', $stats[4]['title']);
+ }
+}
diff --git a/tests/units/Analytic/EstimatedTimeComparisonAnalyticTest.php b/tests/units/Analytic/EstimatedTimeComparisonAnalyticTest.php
new file mode 100644
index 00000000..2ca631b1
--- /dev/null
+++ b/tests/units/Analytic/EstimatedTimeComparisonAnalyticTest.php
@@ -0,0 +1,99 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Analytic\EstimatedTimeComparisonAnalytic;
+
+class EstimatedTimeComparisonAnalyticTest extends Base
+{
+ public function testBuild()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $estimatedTimeComparisonAnalytic = new EstimatedTimeComparisonAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 5.5)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 1.75)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 1.25, 'is_active' => 0)));
+
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 8.25)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 0.25)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 0.5, 'is_active' => 0)));
+
+ $expected = array(
+ 'open' => array(
+ 'time_spent' => 8.5,
+ 'time_estimated' => 7.25,
+ ),
+ 'closed' => array(
+ 'time_spent' => 0.5,
+ 'time_estimated' => 1.25,
+ )
+ );
+
+ $this->assertEquals($expected, $estimatedTimeComparisonAnalytic->build(1));
+ }
+
+ public function testBuildWithNoClosedTask()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $estimatedTimeComparisonAnalytic = new EstimatedTimeComparisonAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 5.5)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 1.75)));
+
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 8.25)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 0.25)));
+
+ $expected = array(
+ 'open' => array(
+ 'time_spent' => 8.5,
+ 'time_estimated' => 7.25,
+ ),
+ 'closed' => array(
+ 'time_spent' => 0,
+ 'time_estimated' => 0,
+ )
+ );
+
+ $this->assertEquals($expected, $estimatedTimeComparisonAnalytic->build(1));
+ }
+
+ public function testBuildWithOnlyClosedTask()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $projectModel = new Project($this->container);
+ $estimatedTimeComparisonAnalytic = new EstimatedTimeComparisonAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 5.5, 'is_active' => 0)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_estimated' => 1.75, 'is_active' => 0)));
+
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 8.25, 'is_active' => 0)));
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'time_spent' => 0.25, 'is_active' => 0)));
+
+ $expected = array(
+ 'closed' => array(
+ 'time_spent' => 8.5,
+ 'time_estimated' => 7.25,
+ ),
+ 'open' => array(
+ 'time_spent' => 0,
+ 'time_estimated' => 0,
+ )
+ );
+
+ $this->assertEquals($expected, $estimatedTimeComparisonAnalytic->build(1));
+ }
+}
diff --git a/tests/units/Analytic/TaskDistributionAnalyticTest.php b/tests/units/Analytic/TaskDistributionAnalyticTest.php
new file mode 100644
index 00000000..531101ef
--- /dev/null
+++ b/tests/units/Analytic/TaskDistributionAnalyticTest.php
@@ -0,0 +1,62 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Analytic\TaskDistributionAnalytic;
+
+class TaskDistributionAnalyticTest extends Base
+{
+ public function testBuild()
+ {
+ $projectModel = new Project($this->container);
+ $taskDistributionModel = new TaskDistributionAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $this->createTasks(1, 20, 1);
+ $this->createTasks(2, 30, 1);
+ $this->createTasks(3, 40, 1);
+ $this->createTasks(4, 10, 1);
+
+ $expected = array(
+ array(
+ 'column_title' => 'Backlog',
+ 'nb_tasks' => 20,
+ 'percentage' => 20.0,
+ ),
+ array(
+ 'column_title' => 'Ready',
+ 'nb_tasks' => 30,
+ 'percentage' => 30.0,
+ ),
+ array(
+ 'column_title' => 'Work in progress',
+ 'nb_tasks' => 40,
+ 'percentage' => 40.0,
+ ),
+ array(
+ 'column_title' => 'Done',
+ 'nb_tasks' => 10,
+ 'percentage' => 10.0,
+ )
+ );
+
+ $this->assertEquals($expected, $taskDistributionModel->build(1));
+ }
+
+ private function createTasks($column_id, $nb_active, $nb_inactive)
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+
+ for ($i = 0; $i < $nb_active; $i++) {
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => $column_id, 'is_active' => 1)));
+ }
+
+ for ($i = 0; $i < $nb_inactive; $i++) {
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => $column_id, 'is_active' => 0)));
+ }
+ }
+}
diff --git a/tests/units/Analytic/UserDistributionAnalyticTest.php b/tests/units/Analytic/UserDistributionAnalyticTest.php
new file mode 100644
index 00000000..bdc136e1
--- /dev/null
+++ b/tests/units/Analytic/UserDistributionAnalyticTest.php
@@ -0,0 +1,83 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\User;
+use Kanboard\Analytic\UserDistributionAnalytic;
+use Kanboard\Core\Security\Role;
+
+class UserDistributionAnalyticTest extends Base
+{
+ public function testBuild()
+ {
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $userModel = new User($this->container);
+ $userDistributionModel = new UserDistributionAnalytic($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test1')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user4')));
+
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 4, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 5, Role::PROJECT_MEMBER));
+
+ $this->createTasks(0, 10, 1);
+ $this->createTasks(2, 30, 1);
+ $this->createTasks(3, 40, 1);
+ $this->createTasks(4, 10, 1);
+ $this->createTasks(5, 10, 1);
+
+ $expected = array(
+ array(
+ 'user' => 'Unassigned',
+ 'nb_tasks' => 10,
+ 'percentage' => 10.0,
+ ),
+ array(
+ 'user' => 'user1',
+ 'nb_tasks' => 30,
+ 'percentage' => 30.0,
+ ),
+ array(
+ 'user' => 'user2',
+ 'nb_tasks' => 40,
+ 'percentage' => 40.0,
+ ),
+ array(
+ 'user' => 'user3',
+ 'nb_tasks' => 10,
+ 'percentage' => 10.0,
+ ),
+ array(
+ 'user' => 'user4',
+ 'nb_tasks' => 10,
+ 'percentage' => 10.0,
+ )
+ );
+
+ $this->assertEquals($expected, $userDistributionModel->build(1));
+ }
+
+ private function createTasks($user_id, $nb_active, $nb_inactive)
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+
+ for ($i = 0; $i < $nb_active; $i++) {
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'owner_id' => $user_id, 'is_active' => 1)));
+ }
+
+ for ($i = 0; $i < $nb_inactive; $i++) {
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'owner_id' => $user_id, 'is_active' => 0)));
+ }
+ }
+}
diff --git a/tests/units/Auth/DatabaseAuthTest.php b/tests/units/Auth/DatabaseAuthTest.php
new file mode 100644
index 00000000..ac099a7e
--- /dev/null
+++ b/tests/units/Auth/DatabaseAuthTest.php
@@ -0,0 +1,62 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Auth\DatabaseAuth;
+use Kanboard\Model\User;
+
+class DatabaseAuthTest extends Base
+{
+ public function testGetName()
+ {
+ $provider = new DatabaseAuth($this->container);
+ $this->assertEquals('Database', $provider->getName());
+ }
+
+ public function testAuthenticate()
+ {
+ $provider = new DatabaseAuth($this->container);
+
+ $provider->setUsername('admin');
+ $provider->setPassword('admin');
+ $this->assertTrue($provider->authenticate());
+
+ $provider->setUsername('admin');
+ $provider->setPassword('test');
+ $this->assertFalse($provider->authenticate());
+ }
+
+ public function testGetUser()
+ {
+ $provider = new DatabaseAuth($this->container);
+ $this->assertEquals(null, $provider->getUser());
+
+ $provider = new DatabaseAuth($this->container);
+ $provider->setUsername('admin');
+ $provider->setPassword('admin');
+
+ $this->assertTrue($provider->authenticate());
+ $this->assertInstanceOf('Kanboard\User\DatabaseUserProvider', $provider->getUser());
+ }
+
+ public function testIsvalidSession()
+ {
+ $userModel = new User($this->container);
+ $provider = new DatabaseAuth($this->container);
+
+ $this->assertFalse($provider->isValidSession());
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'foobar')));
+
+ $this->container['sessionStorage']->user = array('id' => 2);
+ $this->assertTrue($provider->isValidSession());
+
+ $this->container['sessionStorage']->user = array('id' => 3);
+ $this->assertFalse($provider->isValidSession());
+
+ $this->assertTrue($userModel->disable(2));
+
+ $this->container['sessionStorage']->user = array('id' => 2);
+ $this->assertFalse($provider->isValidSession());
+ }
+}
diff --git a/tests/units/Auth/LdapTest.php b/tests/units/Auth/LdapTest.php
deleted file mode 100644
index 19e7d7e2..00000000
--- a/tests/units/Auth/LdapTest.php
+++ /dev/null
@@ -1,697 +0,0 @@
-<?php
-
-namespace Kanboard\Auth;
-
-require_once __DIR__.'/../Base.php';
-
-function ldap_connect($hostname, $port)
-{
- return LdapTest::$functions->ldap_connect($hostname, $port);
-}
-
-function ldap_set_option()
-{
-}
-
-function ldap_bind($link_identifier, $bind_rdn, $bind_password)
-{
- return LdapTest::$functions->ldap_bind($link_identifier, $bind_rdn, $bind_password);
-}
-
-function ldap_search($link_identifier, $base_dn, $filter, array $attributes)
-{
- return LdapTest::$functions->ldap_search($link_identifier, $base_dn, $filter, $attributes);
-}
-
-function ldap_get_entries($link_identifier, $result_identifier)
-{
- return LdapTest::$functions->ldap_get_entries($link_identifier, $result_identifier);
-}
-
-class LdapTest extends \Base
-{
- public static $functions;
- private $ldap;
-
- public function setUp()
- {
- parent::setup();
-
- self::$functions = $this
- ->getMockBuilder('stdClass')
- ->setMethods(array(
- 'ldap_connect',
- 'ldap_set_option',
- 'ldap_bind',
- 'ldap_search',
- 'ldap_get_entries',
- ))
- ->getMock();
- }
-
- public function tearDown()
- {
- parent::tearDown();
- self::$functions = null;
- }
-
- public function testGetAttributes()
- {
- $ldap = new Ldap($this->container);
- $this->assertCount(3, $ldap->getProfileAttributes());
- $this->assertContains(LDAP_ACCOUNT_FULLNAME, $ldap->getProfileAttributes());
- $this->assertContains(LDAP_ACCOUNT_EMAIL, $ldap->getProfileAttributes());
- $this->assertContains(LDAP_ACCOUNT_MEMBEROF, $ldap->getProfileAttributes());
- }
-
- public function testConnectSuccess()
- {
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapServer'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapServer')
- ->will($this->returnValue('my_ldap_server'));
-
- self::$functions
- ->expects($this->once())
- ->method('ldap_connect')
- ->with(
- $this->equalTo('my_ldap_server'),
- $this->equalTo($ldap->getLdapPort())
- )
- ->will($this->returnValue('my_ldap_resource'));
-
- $this->assertNotFalse($ldap->connect());
- }
-
- public function testConnectFailure()
- {
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapServer'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapServer')
- ->will($this->returnValue('my_ldap_server'));
-
- self::$functions
- ->expects($this->once())
- ->method('ldap_connect')
- ->with(
- $this->equalTo('my_ldap_server'),
- $this->equalTo($ldap->getLdapPort())
- )
- ->will($this->returnValue(false));
-
- $this->assertFalse($ldap->connect());
- }
-
- public function testBindAnonymous()
- {
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapBindType'))
- ->getMock();
-
- $ldap
- ->expects($this->any())
- ->method('getLdapBindType')
- ->will($this->returnValue('anonymous'));
-
- self::$functions
- ->expects($this->once())
- ->method('ldap_bind')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo(null),
- $this->equalTo(null)
- )
- ->will($this->returnValue(true));
-
- $this->assertTrue($ldap->bind('my_ldap_connection', 'my_user', 'my_password'));
- }
-
- public function testBindUser()
- {
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapUsername', 'getLdapBindType'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapUsername')
- ->will($this->returnValue('uid=my_user'));
-
- $ldap
- ->expects($this->any())
- ->method('getLdapBindType')
- ->will($this->returnValue('user'));
-
- self::$functions
- ->expects($this->once())
- ->method('ldap_bind')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('uid=my_user'),
- $this->equalTo('my_password')
- )
- ->will($this->returnValue(true));
-
- $this->assertTrue($ldap->bind('my_ldap_connection', 'my_user', 'my_password'));
- }
-
- public function testBindProxy()
- {
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapUsername', 'getLdapPassword', 'getLdapBindType'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapUsername')
- ->will($this->returnValue('someone'));
-
- $ldap
- ->expects($this->once())
- ->method('getLdapPassword')
- ->will($this->returnValue('something'));
-
- $ldap
- ->expects($this->any())
- ->method('getLdapBindType')
- ->will($this->returnValue('proxy'));
-
- self::$functions
- ->expects($this->once())
- ->method('ldap_bind')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('someone'),
- $this->equalTo('something')
- )
- ->will($this->returnValue(true));
-
- $this->assertTrue($ldap->bind('my_ldap_connection', 'my_user', 'my_password'));
- }
-
- public function testSearchSuccess()
- {
- $entries = array(
- 'count' => 1,
- 0 => array(
- 'count' => 2,
- 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local',
- 'displayname' => array(
- 'count' => 1,
- 0 => 'My user',
- ),
- 'mail' => array(
- 'count' => 2,
- 0 => 'user1@localhost',
- 1 => 'user2@localhost',
- ),
- 0 => 'displayname',
- 1 => 'mail',
- )
- );
-
- $expected = array(
- 'username' => 'my_user',
- 'name' => 'My user',
- 'email' => 'user1@localhost',
- 'is_admin' => 0,
- 'is_project_admin' => 0,
- 'is_ldap_user' => 1,
- );
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapUserPattern', 'getLdapBaseDn'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapUserPattern')
- ->will($this->returnValue('uid=my_user'));
-
- $ldap
- ->expects($this->once())
- ->method('getLdapBaseDn')
- ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
-
- self::$functions
- ->expects($this->at(0))
- ->method('ldap_search')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('ou=People,dc=kanboard,dc=local'),
- $this->equalTo('uid=my_user'),
- $this->equalTo($ldap->getProfileAttributes())
- )
- ->will($this->returnValue('my_result_identifier'));
-
- self::$functions
- ->expects($this->at(1))
- ->method('ldap_get_entries')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('my_result_identifier')
- )
- ->will($this->returnValue($entries));
-
- self::$functions
- ->expects($this->at(2))
- ->method('ldap_bind')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('uid=my_user,ou=People,dc=kanboard,dc=local'),
- $this->equalTo('my_password')
- )
- ->will($this->returnValue(true));
-
- $this->assertEquals($expected, $ldap->getProfile('my_ldap_connection', 'my_user', 'my_password'));
- }
-
- public function testSearchWithBadPassword()
- {
- $entries = array(
- 'count' => 1,
- 0 => array(
- 'count' => 2,
- 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local',
- 'displayname' => array(
- 'count' => 1,
- 0 => 'My user',
- ),
- 'mail' => array(
- 'count' => 2,
- 0 => 'user1@localhost',
- 1 => 'user2@localhost',
- ),
- 0 => 'displayname',
- 1 => 'mail',
- )
- );
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapUserPattern', 'getLdapBaseDn'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapUserPattern')
- ->will($this->returnValue('uid=my_user'));
-
- $ldap
- ->expects($this->once())
- ->method('getLdapBaseDn')
- ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
-
- self::$functions
- ->expects($this->at(0))
- ->method('ldap_search')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('ou=People,dc=kanboard,dc=local'),
- $this->equalTo('uid=my_user'),
- $this->equalTo($ldap->getProfileAttributes())
- )
- ->will($this->returnValue('my_result_identifier'));
-
- self::$functions
- ->expects($this->at(1))
- ->method('ldap_get_entries')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('my_result_identifier')
- )
- ->will($this->returnValue($entries));
-
- self::$functions
- ->expects($this->at(2))
- ->method('ldap_bind')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('uid=my_user,ou=People,dc=kanboard,dc=local'),
- $this->equalTo('my_password')
- )
- ->will($this->returnValue(false));
-
- $this->assertFalse($ldap->getProfile('my_ldap_connection', 'my_user', 'my_password'));
- }
-
- public function testSearchWithUserNotFound()
- {
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getLdapUserPattern', 'getLdapBaseDn'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('getLdapUserPattern')
- ->will($this->returnValue('uid=my_user'));
-
- $ldap
- ->expects($this->once())
- ->method('getLdapBaseDn')
- ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
-
- self::$functions
- ->expects($this->at(0))
- ->method('ldap_search')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('ou=People,dc=kanboard,dc=local'),
- $this->equalTo('uid=my_user'),
- $this->equalTo($ldap->getProfileAttributes())
- )
- ->will($this->returnValue('my_result_identifier'));
-
- self::$functions
- ->expects($this->at(1))
- ->method('ldap_get_entries')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('my_result_identifier')
- )
- ->will($this->returnValue(array()));
-
- $this->assertFalse($ldap->getProfile('my_ldap_connection', 'my_user', 'my_password'));
- }
-
- public function testSuccessfulAuthentication()
- {
- $this->container['userSession'] = $this
- ->getMockBuilder('\Kanboard\Model\UserSession')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('refresh'))
- ->getMock();
-
- $this->container['user'] = $this
- ->getMockBuilder('\Kanboard\Model\User')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getByUsername'))
- ->getMock();
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('findUser'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('findUser')
- ->with(
- $this->equalTo('user'),
- $this->equalTo('password')
- )
- ->will($this->returnValue(array('username' => 'user', 'name' => 'My user', 'email' => 'user@here')));
-
- $this->container['user']
- ->expects($this->once())
- ->method('getByUsername')
- ->with(
- $this->equalTo('user')
- )
- ->will($this->returnValue(array('id' => 2, 'username' => 'user', 'is_ldap_user' => 1)));
-
- $this->container['userSession']
- ->expects($this->once())
- ->method('refresh');
-
- $this->assertTrue($ldap->authenticate('user', 'password'));
- }
-
- public function testAuthenticationWithExistingLocalUser()
- {
- $this->container['userSession'] = $this
- ->getMockBuilder('\Kanboard\Model\UserSession')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('refresh'))
- ->getMock();
-
- $this->container['user'] = $this
- ->getMockBuilder('\Kanboard\Model\User')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getByUsername'))
- ->getMock();
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('findUser'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('findUser')
- ->with(
- $this->equalTo('user'),
- $this->equalTo('password')
- )
- ->will($this->returnValue(array('username' => 'user', 'name' => 'My user', 'email' => 'user@here')));
-
- $this->container['user']
- ->expects($this->once())
- ->method('getByUsername')
- ->with(
- $this->equalTo('user')
- )
- ->will($this->returnValue(array('id' => 2, 'username' => 'user', 'is_ldap_user' => 0)));
-
- $this->container['userSession']
- ->expects($this->never())
- ->method('refresh');
-
- $this->assertFalse($ldap->authenticate('user', 'password'));
- }
-
- public function testAuthenticationWithAutomaticAccountCreation()
- {
- $ldap_profile = array('username' => 'user', 'name' => 'My user', 'email' => 'user@here');
-
- $this->container['userSession'] = $this
- ->getMockBuilder('\Kanboard\Model\UserSession')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('refresh'))
- ->getMock();
-
- $this->container['user'] = $this
- ->getMockBuilder('\Kanboard\Model\User')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getByUsername', 'create'))
- ->getMock();
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('findUser'))
- ->getMock();
-
- $ldap
- ->expects($this->at(0))
- ->method('findUser')
- ->with(
- $this->equalTo('user'),
- $this->equalTo('password')
- )
- ->will($this->returnValue($ldap_profile));
-
- $this->container['user']
- ->expects($this->at(0))
- ->method('getByUsername')
- ->with(
- $this->equalTo('user')
- )
- ->will($this->returnValue(null));
-
- $this->container['user']
- ->expects($this->at(1))
- ->method('create')
- ->with(
- $this->equalTo($ldap_profile)
- )
- ->will($this->returnValue(true));
-
- $this->container['user']
- ->expects($this->at(2))
- ->method('getByUsername')
- ->with(
- $this->equalTo('user')
- )
- ->will($this->returnValue(array('id' => 2, 'username' => 'user', 'is_ldap_user' => 1)));
-
- $this->container['userSession']
- ->expects($this->once())
- ->method('refresh');
-
- $this->assertTrue($ldap->authenticate('user', 'password'));
- }
-
- public function testAuthenticationWithAutomaticAccountCreationFailed()
- {
- $ldap_profile = array('username' => 'user', 'name' => 'My user', 'email' => 'user@here');
-
- $this->container['userSession'] = $this
- ->getMockBuilder('\Kanboard\Model\UserSession')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('refresh'))
- ->getMock();
-
- $this->container['user'] = $this
- ->getMockBuilder('\Kanboard\Model\User')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('getByUsername', 'create'))
- ->getMock();
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('findUser'))
- ->getMock();
-
- $ldap
- ->expects($this->at(0))
- ->method('findUser')
- ->with(
- $this->equalTo('user'),
- $this->equalTo('password')
- )
- ->will($this->returnValue($ldap_profile));
-
- $this->container['user']
- ->expects($this->at(0))
- ->method('getByUsername')
- ->with(
- $this->equalTo('user')
- )
- ->will($this->returnValue(null));
-
- $this->container['user']
- ->expects($this->at(1))
- ->method('create')
- ->with(
- $this->equalTo($ldap_profile)
- )
- ->will($this->returnValue(false));
-
- $this->container['userSession']
- ->expects($this->never())
- ->method('refresh');
-
- $this->assertFalse($ldap->authenticate('user', 'password'));
- }
-
- public function testLookup()
- {
- $entries = array(
- 'count' => 1,
- 0 => array(
- 'count' => 2,
- 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local',
- 'displayname' => array(
- 'count' => 1,
- 0 => 'My LDAP user',
- ),
- 'mail' => array(
- 'count' => 2,
- 0 => 'user1@localhost',
- 1 => 'user2@localhost',
- ),
- 'samaccountname' => array(
- 'count' => 1,
- 0 => 'my_ldap_user',
- ),
- 0 => 'displayname',
- 1 => 'mail',
- 2 => 'samaccountname',
- )
- );
-
- $expected = array(
- 'username' => 'my_ldap_user',
- 'name' => 'My LDAP user',
- 'email' => 'user1@localhost',
- 'is_admin' => 0,
- 'is_project_admin' => 0,
- 'is_ldap_user' => 1,
- );
-
- $ldap = $this
- ->getMockBuilder('\Kanboard\Auth\Ldap')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('connect', 'getLdapUserPattern', 'getLdapBaseDn', 'getLdapAccountId'))
- ->getMock();
-
- $ldap
- ->expects($this->once())
- ->method('connect')
- ->will($this->returnValue('my_ldap_connection'));
-
- $ldap
- ->expects($this->once())
- ->method('getLdapUserPattern')
- ->will($this->returnValue('sAMAccountName=my_user'));
-
- $ldap
- ->expects($this->any())
- ->method('getLdapAccountId')
- ->will($this->returnValue('samaccountname'));
-
- $ldap
- ->expects($this->once())
- ->method('getLdapBaseDn')
- ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
-
- self::$functions
- ->expects($this->at(0))
- ->method('ldap_bind')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo(null),
- $this->equalTo(null)
- )
- ->will($this->returnValue(true));
-
- self::$functions
- ->expects($this->at(1))
- ->method('ldap_search')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('ou=People,dc=kanboard,dc=local'),
- $this->equalTo('(&(sAMAccountName=my_user)(mail=user@localhost))'),
- $this->equalTo($ldap->getProfileAttributes())
- )
- ->will($this->returnValue('my_result_identifier'));
-
- self::$functions
- ->expects($this->at(2))
- ->method('ldap_get_entries')
- ->with(
- $this->equalTo('my_ldap_connection'),
- $this->equalTo('my_result_identifier')
- )
- ->will($this->returnValue($entries));
-
- $this->assertEquals($expected, $ldap->lookup('my_user', 'user@localhost'));
- }
-}
diff --git a/tests/units/Auth/ReverseProxyTest.php b/tests/units/Auth/ReverseProxyTest.php
deleted file mode 100644
index 6aaa5a67..00000000
--- a/tests/units/Auth/ReverseProxyTest.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Auth\ReverseProxy;
-use Kanboard\Model\User;
-
-class ReverseProxyTest extends Base
-{
- public function setUp()
- {
- parent::setup();
- $_SERVER = array();
- }
-
- public function testFailedAuthentication()
- {
- $auth = new ReverseProxy($this->container);
- $this->assertFalse($auth->authenticate());
- }
-
- public function testSuccessfulAuthentication()
- {
- $_SERVER[REVERSE_PROXY_USER_HEADER] = 'my_user';
-
- $a = new ReverseProxy($this->container);
- $u = new User($this->container);
-
- $this->assertTrue($a->authenticate());
-
- $user = $u->getByUsername('my_user');
- $this->assertNotEmpty($user);
- $this->assertEquals(0, $user['is_admin']);
- $this->assertEquals(1, $user['is_ldap_user']);
- $this->assertEquals(1, $user['disable_login_form']);
- }
-}
diff --git a/tests/units/Auth/TotpAuthTest.php b/tests/units/Auth/TotpAuthTest.php
new file mode 100644
index 00000000..c8dcfb28
--- /dev/null
+++ b/tests/units/Auth/TotpAuthTest.php
@@ -0,0 +1,66 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Auth\TotpAuth;
+
+class TotpAuthTest extends Base
+{
+ public function testGetName()
+ {
+ $provider = new TotpAuth($this->container);
+ $this->assertEquals('Time-based One-time Password Algorithm', $provider->getName());
+ }
+
+ public function testGetSecret()
+ {
+ $provider = new TotpAuth($this->container);
+ $this->assertEmpty($provider->getSecret());
+
+ $provider->generateSecret();
+ $secret = $provider->getSecret();
+
+ $this->assertNotEmpty($secret);
+ $this->assertEquals($secret, $provider->getSecret());
+ $this->assertEquals($secret, $provider->getSecret());
+ }
+
+ public function testSetSecret()
+ {
+ $provider = new TotpAuth($this->container);
+ $provider->setSecret('mySecret');
+ $this->assertEquals('mySecret', $provider->getSecret());
+ }
+
+ public function testGetUrl()
+ {
+ $provider = new TotpAuth($this->container);
+ $this->assertEmpty($provider->getQrCodeUrl('me'));
+ $this->assertEmpty($provider->getKeyUrl('me'));
+
+ $provider->setSecret('mySecret');
+ $this->assertEquals(
+ 'https://chart.googleapis.com/chart?chs=200x200&cht=qr&chld=M|0&chl=otpauth%3A%2F%2Ftotp%2Fme%3Fsecret%3DmySecret',
+ $provider->getQrCodeUrl('me')
+ );
+
+ $this->assertEquals('otpauth://totp/me?secret=mySecret', $provider->getKeyUrl('me'));
+ }
+
+ public function testAuthentication()
+ {
+ $provider = new TotpAuth($this->container);
+
+ $secret = $provider->generateSecret();
+ $this->assertNotEmpty($secret);
+
+ $provider->setCode('1234');
+ $this->assertFalse($provider->authenticate());
+
+ if (!!`which oathtool`) {
+ $code = shell_exec('oathtool --totp -b '.$secret);
+ $provider->setCode(trim($code));
+ $this->assertTrue($provider->authenticate());
+ }
+ }
+}
diff --git a/tests/units/Base.php b/tests/units/Base.php
index 8112c954..eb9fc68b 100644
--- a/tests/units/Base.php
+++ b/tests/units/Base.php
@@ -8,49 +8,9 @@ use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
use Symfony\Component\Stopwatch\Stopwatch;
use SimpleLogger\Logger;
use SimpleLogger\File;
-
-class FakeHttpClient
-{
- private $url = '';
- private $data = array();
- private $headers = array();
-
- public function getUrl()
- {
- return $this->url;
- }
-
- public function getData()
- {
- return $this->data;
- }
-
- public function getHeaders()
- {
- return $this->headers;
- }
-
- public function toPrettyJson()
- {
- return json_encode($this->data, JSON_PRETTY_PRINT);
- }
-
- 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;
- $this->headers = $headers;
- return true;
- }
-}
+use Kanboard\Core\Session\FlashMessage;
+use Kanboard\Core\Session\SessionStorage;
+use Kanboard\ServiceProvider\ActionProvider;
abstract class Base extends PHPUnit_Framework_TestCase
{
@@ -73,8 +33,11 @@ abstract class Base extends PHPUnit_Framework_TestCase
}
$this->container = new Pimple\Container;
+ $this->container->register(new Kanboard\ServiceProvider\AuthenticationProvider);
$this->container->register(new Kanboard\ServiceProvider\DatabaseProvider);
$this->container->register(new Kanboard\ServiceProvider\ClassProvider);
+ $this->container->register(new Kanboard\ServiceProvider\NotificationProvider);
+ $this->container->register(new Kanboard\ServiceProvider\RouteProvider);
$this->container['dispatcher'] = new TraceableEventDispatcher(
new EventDispatcher,
@@ -85,14 +48,37 @@ abstract class Base extends PHPUnit_Framework_TestCase
$this->container['logger'] = new Logger;
$this->container['logger']->setLogger(new File($this->isWindows() ? 'NUL' : '/dev/null'));
- $this->container['httpClient'] = new FakeHttpClient;
- $this->container['emailClient'] = $this->getMockBuilder('EmailClient')->setMethods(array('send'))->getMock();
+
+ $this->container['httpClient'] = $this
+ ->getMockBuilder('\Kanboard\Core\Http\Client')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('get', 'getJson', 'postJson', 'postForm'))
+ ->getMock();
+
+ $this->container['emailClient'] = $this
+ ->getMockBuilder('\Kanboard\Core\Mail\Client')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('send'))
+ ->getMock();
$this->container['userNotificationType'] = $this
->getMockBuilder('\Kanboard\Model\UserNotificationType')
->setConstructorArgs(array($this->container))
->setMethods(array('getType', 'getSelectedTypes'))
->getMock();
+
+ $this->container['objectStorage'] = $this
+ ->getMockBuilder('\Kanboard\Core\ObjectStorage\FileStorage')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('put', 'moveFile', 'remove', 'moveUploadedFile'))
+ ->getMock();
+
+ $this->container['sessionStorage'] = new SessionStorage;
+ $this->container->register(new ActionProvider);
+
+ $this->container['flash'] = function ($c) {
+ return new FlashMessage($c);
+ };
}
public function tearDown()
diff --git a/tests/units/Core/Action/ActionManagerTest.php b/tests/units/Core/Action/ActionManagerTest.php
new file mode 100644
index 00000000..aae5bd2d
--- /dev/null
+++ b/tests/units/Core/Action/ActionManagerTest.php
@@ -0,0 +1,196 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Action\ActionManager;
+use Kanboard\Action\TaskAssignColorColumn;
+use Kanboard\Action\TaskClose;
+use Kanboard\Action\TaskCloseColumn;
+use Kanboard\Action\TaskUpdateStartDate;
+use Kanboard\Model\Action;
+use Kanboard\Model\Task;
+use Kanboard\Model\Project;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Core\Security\Role;
+
+class ActionManagerTest extends Base
+{
+ public function testRegister()
+ {
+ $actionManager = new ActionManager($this->container);
+ $actionTaskClose = new TaskClose($this->container);
+
+ $actionManager->register($actionTaskClose);
+ $this->assertInstanceOf(get_class($actionTaskClose), $actionManager->getAction($actionTaskClose->getName()));
+ }
+
+ public function testGetActionNotFound()
+ {
+ $this->setExpectedException('RuntimeException', 'Automatic Action Not Found: foobar');
+ $actionManager = new ActionManager($this->container);
+ $actionManager->getAction('foobar');
+ }
+
+ public function testGetAvailableActions()
+ {
+ $actionManager = new ActionManager($this->container);
+ $actionTaskClose1 = new TaskCloseColumn($this->container);
+ $actionTaskClose2 = new TaskCloseColumn($this->container);
+ $actionTaskUpdateStartDate = new TaskUpdateStartDate($this->container);
+
+ $actionManager
+ ->register($actionTaskClose1)
+ ->register($actionTaskClose2)
+ ->register($actionTaskUpdateStartDate);
+
+ $actions = $actionManager->getAvailableActions();
+ $this->assertCount(2, $actions);
+ $this->assertArrayHasKey($actionTaskClose1->getName(), $actions);
+ $this->assertArrayHasKey($actionTaskUpdateStartDate->getName(), $actions);
+ $this->assertNotEmpty($actions[$actionTaskClose1->getName()]);
+ $this->assertNotEmpty($actions[$actionTaskUpdateStartDate->getName()]);
+ }
+
+ public function testGetAvailableParameters()
+ {
+ $actionManager = new ActionManager($this->container);
+
+ $actionManager
+ ->register(new TaskCloseColumn($this->container))
+ ->register(new TaskUpdateStartDate($this->container));
+
+ $params = $actionManager->getAvailableParameters(array(
+ array('action_name' => '\Kanboard\Action\TaskCloseColumn'),
+ array('action_name' => '\Kanboard\Action\TaskUpdateStartDate'),
+ ));
+
+ $this->assertCount(2, $params);
+ $this->assertArrayHasKey('column_id', $params['\Kanboard\Action\TaskCloseColumn']);
+ $this->assertArrayHasKey('column_id', $params['\Kanboard\Action\TaskUpdateStartDate']);
+ $this->assertNotEmpty($params['\Kanboard\Action\TaskCloseColumn']['column_id']);
+ $this->assertNotEmpty($params['\Kanboard\Action\TaskUpdateStartDate']['column_id']);
+ }
+
+ public function testGetCompatibleEvents()
+ {
+ $actionTaskAssignColorColumn = new TaskAssignColorColumn($this->container);
+ $actionManager = new ActionManager($this->container);
+ $actionManager->register($actionTaskAssignColorColumn);
+
+ $events = $actionManager->getCompatibleEvents('\\'.get_class($actionTaskAssignColorColumn));
+ $this->assertCount(2, $events);
+ $this->assertArrayHasKey(Task::EVENT_CREATE, $events);
+ $this->assertArrayHasKey(Task::EVENT_MOVE_COLUMN, $events);
+ $this->assertNotEmpty($events[Task::EVENT_CREATE]);
+ $this->assertNotEmpty($events[Task::EVENT_MOVE_COLUMN]);
+ }
+
+ public function testAttachEventsWithoutUserSession()
+ {
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+ $actionTaskAssignColorColumn = new TaskAssignColorColumn($this->container);
+ $actionManager = new ActionManager($this->container);
+ $actionManager->register($actionTaskAssignColorColumn);
+
+ $actions = $actionManager->getAvailableActions();
+
+ $actionManager->attachEvents();
+ $this->assertEmpty($this->container['dispatcher']->getListeners());
+
+ $this->assertEquals(1, $projectModel->create(array('name' =>'test')));
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => key($actions),
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $actionManager->attachEvents();
+ $listeners = $this->container['dispatcher']->getListeners(Task::EVENT_CREATE);
+ $this->assertCount(1, $listeners);
+ $this->assertInstanceOf(get_class($actionTaskAssignColorColumn), $listeners[0][0]);
+
+ $this->assertEquals(1, $listeners[0][0]->getProjectId());
+ }
+
+ public function testAttachEventsWithLoggedUser()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $actionModel = new Action($this->container);
+ $actionTaskAssignColorColumn = new TaskAssignColorColumn($this->container);
+ $actionManager = new ActionManager($this->container);
+ $actionManager->register($actionTaskAssignColorColumn);
+
+ $actions = $actionManager->getAvailableActions();
+
+ $this->assertEquals(1, $projectModel->create(array('name' =>'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' =>'test2')));
+
+ $this->assertTrue($projectUserRoleModel->addUser(2, 1, Role::PROJECT_MEMBER));
+
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => key($actions),
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $this->assertEquals(2, $actionModel->create(array(
+ 'project_id' => 2,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => key($actions),
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $actionManager->attachEvents();
+
+ $listeners = $this->container['dispatcher']->getListeners(Task::EVENT_MOVE_COLUMN);
+ $this->assertCount(1, $listeners);
+ $this->assertInstanceOf(get_class($actionTaskAssignColorColumn), $listeners[0][0]);
+
+ $this->assertEquals(2, $listeners[0][0]->getProjectId());
+ }
+
+ public function testThatEachListenerAreDifferentInstance()
+ {
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $actionModel = new Action($this->container);
+ $actionTaskAssignColorColumn = new TaskAssignColorColumn($this->container);
+ $actionManager = new ActionManager($this->container);
+ $actionManager->register($actionTaskAssignColorColumn);
+
+ $this->assertEquals(1, $projectModel->create(array('name' =>'test1')));
+ $actions = $actionManager->getAvailableActions();
+
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => key($actions),
+ 'params' => array('column_id' => 2, 'color_id' => 'green'),
+ )));
+
+ $this->assertEquals(2, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => key($actions),
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $actionManager->attachEvents();
+
+ $listeners = $this->container['dispatcher']->getListeners(Task::EVENT_MOVE_COLUMN);
+ $this->assertCount(2, $listeners);
+ $this->assertFalse($listeners[0][0] === $listeners[1][0]);
+
+ $this->assertEquals(2, $listeners[0][0]->getParam('column_id'));
+ $this->assertEquals('green', $listeners[0][0]->getParam('color_id'));
+
+ $this->assertEquals(1, $listeners[1][0]->getParam('column_id'));
+ $this->assertEquals('red', $listeners[1][0]->getParam('color_id'));
+ }
+}
diff --git a/tests/units/Core/CsvTest.php b/tests/units/Core/CsvTest.php
index 71542c20..da0be4a3 100644
--- a/tests/units/Core/CsvTest.php
+++ b/tests/units/Core/CsvTest.php
@@ -14,9 +14,43 @@ class CsvTest extends Base
$this->assertEquals(1, Csv::getBooleanValue('TRUE'));
$this->assertEquals(1, Csv::getBooleanValue('true'));
$this->assertEquals(1, Csv::getBooleanValue('T'));
+ $this->assertEquals(1, Csv::getBooleanValue('Y'));
+ $this->assertEquals(1, Csv::getBooleanValue('y'));
+ $this->assertEquals(1, Csv::getBooleanValue('yes'));
+ $this->assertEquals(1, Csv::getBooleanValue('Yes'));
$this->assertEquals(0, Csv::getBooleanValue('0'));
$this->assertEquals(0, Csv::getBooleanValue('123'));
$this->assertEquals(0, Csv::getBooleanValue('anything'));
}
+
+ public function testGetEnclosures()
+ {
+ $this->assertCount(3, Csv::getEnclosures());
+ $this->assertCount(4, Csv::getDelimiters());
+ }
+
+ public function testReadWrite()
+ {
+ $filename = tempnam(sys_get_temp_dir(), 'UT');
+ $rows = array(
+ array('Column A', 'Column B'),
+ array('value a', 'value b'),
+ );
+
+ $csv = new Csv;
+ $csv->write($filename, $rows);
+ $csv->setColumnMapping(array('A', 'B', 'C'));
+ $csv->read($filename, array($this, 'readRow'));
+
+ unlink($filename);
+
+ $this->expectOutputString('"Column A","Column B"'."\n".'"value a","value b"'."\n", $csv->output($rows));
+ }
+
+ public function readRow(array $row, $line)
+ {
+ $this->assertEquals(array('value a', 'value b', ''), $row);
+ $this->assertEquals(1, $line);
+ }
}
diff --git a/tests/units/Core/DateParserTest.php b/tests/units/Core/DateParserTest.php
index 0d345784..dc3366b3 100644
--- a/tests/units/Core/DateParserTest.php
+++ b/tests/units/Core/DateParserTest.php
@@ -6,79 +6,167 @@ use Kanboard\Core\DateParser;
class DateParserTest extends Base
{
+ public function testGetTimeFormats()
+ {
+ $dateParser = new DateParser($this->container);
+ $this->assertCount(2, $dateParser->getTimeFormats());
+ $this->assertContains('H:i', $dateParser->getTimeFormats());
+ $this->assertContains('g:i a', $dateParser->getTimeFormats());
+ }
+
+ public function testGetDateFormats()
+ {
+ $dateParser = new DateParser($this->container);
+ $this->assertCount(4, $dateParser->getDateFormats());
+ $this->assertCount(6, $dateParser->getDateFormats(true));
+ $this->assertContains('d/m/Y', $dateParser->getDateFormats());
+ $this->assertNotContains('Y-m-d', $dateParser->getDateFormats());
+ $this->assertContains('Y-m-d', $dateParser->getDateFormats(true));
+ }
+
+ public function testGetDateTimeFormats()
+ {
+ $dateParser = new DateParser($this->container);
+ $this->assertCount(8, $dateParser->getDateTimeFormats());
+ $this->assertCount(12, $dateParser->getDateTimeFormats(true));
+ $this->assertContains('d/m/Y H:i', $dateParser->getDateTimeFormats());
+ $this->assertNotContains('Y-m-d H:i', $dateParser->getDateTimeFormats());
+ $this->assertContains('Y-m-d g:i a', $dateParser->getDateTimeFormats(true));
+ }
+
+ public function testGetAllDateFormats()
+ {
+ $dateParser = new DateParser($this->container);
+ $this->assertCount(12, $dateParser->getAllDateFormats());
+ $this->assertCount(18, $dateParser->getAllDateFormats(true));
+ $this->assertContains('d/m/Y', $dateParser->getAllDateFormats());
+ $this->assertContains('d/m/Y H:i', $dateParser->getAllDateFormats());
+ $this->assertNotContains('Y-m-d H:i', $dateParser->getAllDateFormats());
+ $this->assertContains('Y-m-d g:i a', $dateParser->getAllDateFormats(true));
+ $this->assertContains('Y-m-d', $dateParser->getAllDateFormats(true));
+ }
+
+ public function testGetAllAvailableFormats()
+ {
+ $dateParser = new DateParser($this->container);
+
+ $formats = $dateParser->getAvailableFormats($dateParser->getDateFormats());
+ $this->assertArrayHasKey('d/m/Y', $formats);
+ $this->assertContains(date('d/m/Y'), $formats);
+
+ $formats = $dateParser->getAvailableFormats($dateParser->getDateTimeFormats());
+ $this->assertArrayHasKey('d/m/Y H:i', $formats);
+ $this->assertContains(date('d/m/Y H:i'), $formats);
+
+ $formats = $dateParser->getAvailableFormats($dateParser->getAllDateFormats());
+ $this->assertArrayHasKey('d/m/Y', $formats);
+ $this->assertContains(date('d/m/Y'), $formats);
+ $this->assertArrayHasKey('d/m/Y H:i', $formats);
+ $this->assertContains(date('d/m/Y H:i'), $formats);
+ }
+
+ public function testGetTimestamp()
+ {
+ $dateParser = new DateParser($this->container);
+
+ $this->assertEquals(1393995600, $dateParser->getTimestamp(1393995600));
+ $this->assertEquals('2014-03-05', date('Y-m-d', $dateParser->getTimestamp('2014-03-05')));
+ $this->assertEquals('2014-03-05', date('Y-m-d', $dateParser->getTimestamp('2014_03_05')));
+ $this->assertEquals('2014-03-05', date('Y-m-d', $dateParser->getTimestamp('03/05/2014')));
+ $this->assertEquals('2014-03-25 17:18', date('Y-m-d H:i', $dateParser->getTimestamp('03/25/2014 5:18 pm')));
+ $this->assertEquals('2014-03-25 05:18', date('Y-m-d H:i', $dateParser->getTimestamp('03/25/2014 5:18 am')));
+ $this->assertEquals('2014-03-25 17:18', date('Y-m-d H:i', $dateParser->getTimestamp('03/25/2014 5:18pm')));
+ $this->assertEquals('2014-03-25 23:14', date('Y-m-d H:i', $dateParser->getTimestamp('03/25/2014 23:14')));
+ $this->assertEquals('2014-03-29 23:14', date('Y-m-d H:i', $dateParser->getTimestamp('2014_03_29 23:14')));
+ $this->assertEquals('2014-03-29 23:14', date('Y-m-d H:i', $dateParser->getTimestamp('2014-03-29 23:14')));
+ }
+
public function testDateRange()
{
- $d = new DateParser($this->container);
+ $dateParser = new DateParser($this->container);
- $this->assertTrue($d->withinDateRange(new DateTime('2015-03-14 15:30:00'), new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 16:00:00')));
- $this->assertFalse($d->withinDateRange(new DateTime('2015-03-14 15:30:00'), new DateTime('2015-03-14 16:00:00'), new DateTime('2015-03-14 17:00:00')));
+ $this->assertTrue($dateParser->withinDateRange(new DateTime('2015-03-14 15:30:00'), new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 16:00:00')));
+ $this->assertFalse($dateParser->withinDateRange(new DateTime('2015-03-14 15:30:00'), new DateTime('2015-03-14 16:00:00'), new DateTime('2015-03-14 17:00:00')));
+ }
+
+ public function testGetHours()
+ {
+ $dateParser = new DateParser($this->container);
+
+ $this->assertEquals(1, $dateParser->getHours(new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 16:00:00')));
+ $this->assertEquals(2.5, $dateParser->getHours(new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 17:30:00')));
+ $this->assertEquals(2.75, $dateParser->getHours(new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 17:45:00')));
+ $this->assertEquals(3, $dateParser->getHours(new DateTime('2015-03-14 14:57:00'), new DateTime('2015-03-14 17:58:00')));
+ $this->assertEquals(3, $dateParser->getHours(new DateTime('2015-03-14 14:57:00'), new DateTime('2015-03-14 11:58:00')));
}
public function testRoundSeconds()
{
- $d = new DateParser($this->container);
- $this->assertEquals('16:30', date('H:i', $d->getRoundedSeconds(strtotime('16:28'))));
- $this->assertEquals('16:00', date('H:i', $d->getRoundedSeconds(strtotime('16:02'))));
- $this->assertEquals('16:15', date('H:i', $d->getRoundedSeconds(strtotime('16:14'))));
- $this->assertEquals('17:00', date('H:i', $d->getRoundedSeconds(strtotime('16:58'))));
+ $dateParser = new DateParser($this->container);
+ $this->assertEquals('16:30', date('H:i', $dateParser->getRoundedSeconds(strtotime('16:28'))));
+ $this->assertEquals('16:00', date('H:i', $dateParser->getRoundedSeconds(strtotime('16:02'))));
+ $this->assertEquals('16:15', date('H:i', $dateParser->getRoundedSeconds(strtotime('16:14'))));
+ $this->assertEquals('17:00', date('H:i', $dateParser->getRoundedSeconds(strtotime('16:58'))));
}
- public function testGetHours()
+ public function testGetIsoDate()
{
- $d = new DateParser($this->container);
+ $dateParser = new DateParser($this->container);
+
+ $this->assertEquals('2016-02-06', $dateParser->getIsoDate(1454786217));
+ $this->assertEquals('2014-03-05', $dateParser->getIsoDate('2014-03-05'));
+ $this->assertEquals('2014-03-05', $dateParser->getIsoDate('2014_03_05'));
+ $this->assertEquals('2014-03-05', $dateParser->getIsoDate('03/05/2014'));
+ $this->assertEquals('2014-03-25', $dateParser->getIsoDate('03/25/2014 5:18 pm'));
+ $this->assertEquals('2014-03-25', $dateParser->getIsoDate('03/25/2014 5:18 am'));
+ $this->assertEquals('2014-03-25', $dateParser->getIsoDate('03/25/2014 5:18pm'));
+ $this->assertEquals('2014-03-25', $dateParser->getIsoDate('03/25/2014 23:14'));
+ $this->assertEquals('2014-03-29', $dateParser->getIsoDate('2014_03_29 23:14'));
+ $this->assertEquals('2014-03-29', $dateParser->getIsoDate('2014-03-29 23:14'));
+ }
- $this->assertEquals(1, $d->getHours(new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 16:00:00')));
- $this->assertEquals(2.5, $d->getHours(new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 17:30:00')));
- $this->assertEquals(2.75, $d->getHours(new DateTime('2015-03-14 15:00:00'), new DateTime('2015-03-14 17:45:00')));
- $this->assertEquals(3, $d->getHours(new DateTime('2015-03-14 14:57:00'), new DateTime('2015-03-14 17:58:00')));
- $this->assertEquals(3, $d->getHours(new DateTime('2015-03-14 14:57:00'), new DateTime('2015-03-14 11:58:00')));
+ public function testGetTimestampFromIsoFormat()
+ {
+ $dateParser = new DateParser($this->container);
+ $this->assertEquals('2014-03-05 00:00', date('Y-m-d H:i', $dateParser->getTimestampFromIsoFormat('2014-03-05')));
+ $this->assertEquals(date('Y-m-d 00:00', strtotime('+2 days')), date('Y-m-d H:i', $dateParser->getTimestampFromIsoFormat(strtotime('+2 days'))));
}
- public function testValidDate()
+ public function testRemoveTimeFromTimestamp()
{
- $d = new DateParser($this->container);
-
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('2014-03-05', 'Y-m-d')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('2014_03_05', 'Y_m_d')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('05/03/2014', 'd/m/Y')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('03/05/2014', 'm/d/Y')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('3/5/2014', 'm/d/Y')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('5/3/2014', 'd/m/Y')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getValidDate('5/3/14', 'd/m/y')));
- $this->assertEquals(0, $d->getValidDate('5/3/14', 'd/m/Y'));
- $this->assertEquals(0, $d->getValidDate('5-3-2014', 'd/m/Y'));
+ $dateParser = new DateParser($this->container);
+ $this->assertEquals('2016-02-06 00:00', date('Y-m-d H:i', $dateParser->removeTimeFromTimestamp(1454786217)));
}
- public function testGetTimestamp()
+ public function testFormat()
{
- $d = new DateParser($this->container);
-
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('2014-03-05')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('2014_03_05')));
- $this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('03/05/2014')));
- $this->assertEquals('2014-03-25 17:18', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 5:18 pm')));
- $this->assertEquals('2014-03-25 05:18', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 5:18 am')));
- $this->assertEquals('2014-03-25 05:18', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 5:18am')));
- $this->assertEquals('2014-03-25 23:14', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 23:14')));
- $this->assertEquals('2014-03-29 23:14', date('Y-m-d H:i', $d->getTimestamp('2014_03_29 23:14')));
- $this->assertEquals('2014-03-29 23:14', date('Y-m-d H:i', $d->getTimestamp('2014-03-29 23:14')));
+ $dateParser = new DateParser($this->container);
+ $values['date'] = '1454787006';
+
+ $this->assertEquals(array('date' => '06/02/2016'), $dateParser->format($values, array('date'), 'd/m/Y'));
+ $this->assertEquals(array('date' => '02/06/2016 7:30 pm'), $dateParser->format($values, array('date'), 'm/d/Y g:i a'));
}
public function testConvert()
{
- $d = new DateParser($this->container);
-
+ $dateParser = new DateParser($this->container);
$values = array(
'date_due' => '2015-01-25',
- 'date_started' => '2015_01_25',
+ 'date_started' => '2015-01-25 17:25',
);
- $d->convert($values, array('date_due', 'date_started'));
+ $this->assertEquals(
+ array('date_due' => 1422144000, 'date_started' => 1422144000),
+ $dateParser->convert($values, array('date_due', 'date_started'))
+ );
- $this->assertEquals(mktime(0, 0, 0, 1, 25, 2015), $values['date_due']);
- $this->assertEquals('2015-01-25', date('Y-m-d', $values['date_due']));
+ $values = array(
+ 'date_started' => '2015-01-25 17:25',
+ );
- $this->assertEquals(mktime(0, 0, 0, 1, 25, 2015), $values['date_started']);
- $this->assertEquals('2015-01-25', date('Y-m-d', $values['date_started']));
+ $this->assertEquals(
+ array('date_started' => 1422206700),
+ $dateParser->convert($values, array('date_due', 'date_started'), true)
+ );
}
}
diff --git a/tests/units/Core/Event/EventManagerTest.php b/tests/units/Core/Event/EventManagerTest.php
new file mode 100644
index 00000000..974fde62
--- /dev/null
+++ b/tests/units/Core/Event/EventManagerTest.php
@@ -0,0 +1,18 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Event\EventManager;
+
+class EventManagerTest extends Base
+{
+ public function testAddEvent()
+ {
+ $eventManager = new EventManager;
+ $eventManager->register('my.event', 'My Event');
+
+ $events = $eventManager->getAll();
+ $this->assertArrayHasKey('my.event', $events);
+ $this->assertEquals('My Event', $events['my.event']);
+ }
+}
diff --git a/tests/units/Core/ExternalLink/ExternalLinkManagerTest.php b/tests/units/Core/ExternalLink/ExternalLinkManagerTest.php
new file mode 100644
index 00000000..d284a80b
--- /dev/null
+++ b/tests/units/Core/ExternalLink/ExternalLinkManagerTest.php
@@ -0,0 +1,120 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\ExternalLink\ExternalLinkManager;
+use Kanboard\ExternalLink\WebLinkProvider;
+use Kanboard\ExternalLink\AttachmentLinkProvider;
+
+class ExternalLinkManagerTest extends Base
+{
+ public function testRegister()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $this->assertInstanceOf(get_class($webLinkProvider), $externalLinkManager->getProvider($webLinkProvider->getType()));
+ $this->assertInstanceOf(get_class($attachmentLinkProvider), $externalLinkManager->getProvider($attachmentLinkProvider->getType()));
+ }
+
+ public function testGetProviderNotFound()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+
+ $this->setExpectedException('\Kanboard\Core\ExternalLink\ExternalLinkProviderNotFound');
+ $externalLinkManager->getProvider('not found');
+ }
+
+ public function testGetTypes()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $this->assertEquals(array(ExternalLinkManager::TYPE_AUTO => 'Auto'), $externalLinkManager->getTypes());
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $this->assertEquals(
+ array(ExternalLinkManager::TYPE_AUTO => 'Auto', 'attachment' => 'Attachment', 'weblink' => 'Web Link'),
+ $externalLinkManager->getTypes()
+ );
+ }
+
+ public function testGetDependencyLabel()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $this->assertSame('Related', $externalLinkManager->getDependencyLabel($webLinkProvider->getType(), 'related'));
+ $this->assertSame('custom', $externalLinkManager->getDependencyLabel($webLinkProvider->getType(), 'custom'));
+ }
+
+ public function testFindProviderNotFound()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $this->setExpectedException('\Kanboard\Core\ExternalLink\ExternalLinkProviderNotFound');
+ $externalLinkManager->find();
+ }
+
+ public function testFindProvider()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $externalLinkManager->setUserInput(array('text' => 'https://google.com/', 'type' => ExternalLinkManager::TYPE_AUTO));
+ $this->assertSame($webLinkProvider, $externalLinkManager->find());
+
+ $externalLinkManager->setUserInput(array('text' => 'https://google.com/file.pdf', 'type' => ExternalLinkManager::TYPE_AUTO));
+ $this->assertSame($attachmentLinkProvider, $externalLinkManager->find());
+ }
+
+ public function testFindProviderWithSelectedType()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $externalLinkManager->setUserInput(array('text' => 'https://google.com/', 'type' => $webLinkProvider->getType()));
+ $this->assertSame($webLinkProvider, $externalLinkManager->find());
+
+ $externalLinkManager->setUserInput(array('text' => 'https://google.com/file.pdf', 'type' => $attachmentLinkProvider->getType()));
+ $this->assertSame($attachmentLinkProvider, $externalLinkManager->find());
+ }
+
+ public function testFindProviderWithSelectedTypeNotFound()
+ {
+ $externalLinkManager = new ExternalLinkManager($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $externalLinkManager->register($webLinkProvider);
+ $externalLinkManager->register($attachmentLinkProvider);
+
+ $this->setExpectedException('\Kanboard\Core\ExternalLink\ExternalLinkProviderNotFound');
+ $externalLinkManager->setUserInput(array('text' => 'https://google.com/', 'type' => 'not found'));
+ $externalLinkManager->find();
+ }
+}
diff --git a/tests/units/Core/Group/GroupManagerTest.php b/tests/units/Core/Group/GroupManagerTest.php
new file mode 100644
index 00000000..faf5501f
--- /dev/null
+++ b/tests/units/Core/Group/GroupManagerTest.php
@@ -0,0 +1,42 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Model\Group;
+use Kanboard\Core\Group\GroupManager;
+use Kanboard\Group\DatabaseBackendGroupProvider;
+
+class GroupManagerTest extends Base
+{
+ public function testFind()
+ {
+ $groupModel = new Group($this->container);
+ $groupManager = new GroupManager;
+
+ $this->assertEquals(1, $groupModel->create('Group 1'));
+ $this->assertEquals(2, $groupModel->create('Group 2'));
+
+ $this->assertEmpty($groupManager->find('group 1'));
+
+ $groupManager->register(new DatabaseBackendGroupProvider($this->container));
+ $groupManager->register(new DatabaseBackendGroupProvider($this->container));
+
+ $groups = $groupManager->find('group 1');
+ $this->assertCount(1, $groups);
+ $this->assertInstanceOf('Kanboard\Group\DatabaseGroupProvider', $groups[0]);
+ $this->assertEquals('Group 1', $groups[0]->getName());
+ $this->assertEquals('', $groups[0]->getExternalId());
+ $this->assertEquals(1, $groups[0]->getInternalId());
+
+ $groups = $groupManager->find('grou');
+ $this->assertCount(2, $groups);
+ $this->assertInstanceOf('Kanboard\Group\DatabaseGroupProvider', $groups[0]);
+ $this->assertInstanceOf('Kanboard\Group\DatabaseGroupProvider', $groups[1]);
+ $this->assertEquals('Group 1', $groups[0]->getName());
+ $this->assertEquals('Group 2', $groups[1]->getName());
+ $this->assertEquals('', $groups[0]->getExternalId());
+ $this->assertEquals('', $groups[1]->getExternalId());
+ $this->assertEquals(1, $groups[0]->getInternalId());
+ $this->assertEquals(2, $groups[1]->getInternalId());
+ }
+}
diff --git a/tests/units/Core/OAuth2Test.php b/tests/units/Core/Http/OAuth2Test.php
index d5713608..c68ae116 100644
--- a/tests/units/Core/OAuth2Test.php
+++ b/tests/units/Core/Http/OAuth2Test.php
@@ -1,8 +1,8 @@
<?php
-require_once __DIR__.'/../Base.php';
+require_once __DIR__.'/../../Base.php';
-use Kanboard\Core\OAuth2;
+use Kanboard\Core\Http\OAuth2;
class OAuth2Test extends Base
{
@@ -27,17 +27,27 @@ class OAuth2Test extends Base
public function testAccessToken()
{
+ $params = array(
+ 'code' => 'something',
+ 'client_id' => 'A',
+ 'client_secret' => 'B',
+ 'redirect_uri' => 'C',
+ 'grant_type' => 'authorization_code',
+ );
+
+ $response = json_encode(array(
+ 'token_type' => 'bearer',
+ 'access_token' => 'plop',
+ ));
+
+ $this->container['httpClient']
+ ->expects($this->once())
+ ->method('postForm')
+ ->with('E', $params, array('Accept: application/json'))
+ ->will($this->returnValue($response));
+
$oauth = new OAuth2($this->container);
$oauth->createService('A', 'B', 'C', 'D', 'E', array('f', 'g'));
$oauth->getAccessToken('something');
-
- $data = $this->container['httpClient']->getData();
- $this->assertEquals('something', $data['code']);
- $this->assertEquals('A', $data['client_id']);
- $this->assertEquals('B', $data['client_secret']);
- $this->assertEquals('C', $data['redirect_uri']);
- $this->assertEquals('authorization_code', $data['grant_type']);
-
- $this->assertEquals('E', $this->container['httpClient']->getUrl());
}
}
diff --git a/tests/units/Core/Http/RememberMeCookieTest.php b/tests/units/Core/Http/RememberMeCookieTest.php
new file mode 100644
index 00000000..ae5606ac
--- /dev/null
+++ b/tests/units/Core/Http/RememberMeCookieTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+require_once __DIR__.'/../../Base.php';
+
+function setcookie($name, $value = "", $expire = 0, $path = "", $domain = "", $secure = false, $httponly = false)
+{
+ return RememberMeCookieTest::$functions->setcookie($name, $value, $expire, $path, $domain, $secure, $httponly);
+}
+
+class RememberMeCookieTest extends \Base
+{
+ public static $functions;
+
+ public function setUp()
+ {
+ parent::setup();
+
+ self::$functions = $this
+ ->getMockBuilder('stdClass')
+ ->setMethods(array(
+ 'setcookie',
+ ))
+ ->getMock();
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+ self::$functions = null;
+ }
+
+ public function testEncode()
+ {
+ $cookie = new RememberMeCookie($this->container);
+ $this->assertEquals('a|b', $cookie->encode('a', 'b'));
+ }
+
+ public function testDecode()
+ {
+ $cookie = new RememberMeCookie($this->container);
+ $this->assertEquals(array('token' => 'a', 'sequence' => 'b'), $cookie->decode('a|b'));
+ }
+
+ public function testHasCookie()
+ {
+ $this->container['request'] = new Request($this->container, array(), array(), array(), array(), array());
+
+ $cookie = new RememberMeCookie($this->container);
+ $this->assertFalse($cookie->hasCookie());
+
+ $this->container['request'] = new Request($this->container, array(), array(), array(), array(), array(RememberMeCookie::COOKIE_NAME => 'miam'));
+ $this->assertTrue($cookie->hasCookie());
+ }
+
+ public function testWrite()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('setcookie')
+ ->with(
+ RememberMeCookie::COOKIE_NAME,
+ 'myToken|mySequence',
+ 1234,
+ '',
+ '',
+ false,
+ true
+ )
+ ->will($this->returnValue(true));
+
+ $cookie = new RememberMeCookie($this->container);
+ $this->assertTrue($cookie->write('myToken', 'mySequence', 1234));
+ }
+
+ public function testRead()
+ {
+ $this->container['request'] = new Request($this->container, array(), array(), array(), array(), array());
+
+ $cookie = new RememberMeCookie($this->container);
+ $this->assertFalse($cookie->read());
+
+ $this->container['request'] = new Request($this->container, array(), array(), array(), array(), array(RememberMeCookie::COOKIE_NAME => 'T|S'));
+
+ $this->assertEquals(array('token' => 'T', 'sequence' => 'S'), $cookie->read());
+ }
+
+ public function testRemove()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('setcookie')
+ ->with(
+ RememberMeCookie::COOKIE_NAME,
+ '',
+ time() - 3600,
+ '',
+ '',
+ false,
+ true
+ )
+ ->will($this->returnValue(true));
+
+ $cookie = new RememberMeCookie($this->container);
+ $this->assertTrue($cookie->remove());
+ }
+}
diff --git a/tests/units/Core/Http/RequestTest.php b/tests/units/Core/Http/RequestTest.php
new file mode 100644
index 00000000..217698f9
--- /dev/null
+++ b/tests/units/Core/Http/RequestTest.php
@@ -0,0 +1,175 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Http\Request;
+
+class RequestTest extends Base
+{
+ public function testGetStringParam()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEquals('', $request->getStringParam('myvar'));
+
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEquals('default', $request->getStringParam('myvar', 'default'));
+
+ $request = new Request($this->container, array(), array('myvar' => 'myvalue'), array(), array(), array());
+ $this->assertEquals('myvalue', $request->getStringParam('myvar'));
+ }
+
+ public function testGetIntegerParam()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEquals(0, $request->getIntegerParam('myvar'));
+
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEquals(5, $request->getIntegerParam('myvar', 5));
+
+ $request = new Request($this->container, array(), array('myvar' => 'myvalue'), array(), array(), array());
+ $this->assertEquals(0, $request->getIntegerParam('myvar'));
+
+ $request = new Request($this->container, array(), array('myvar' => '123'), array(), array(), array());
+ $this->assertEquals(123, $request->getIntegerParam('myvar'));
+ }
+
+ public function testGetValues()
+ {
+ $request = new Request($this->container, array(), array(), array('myvar' => 'myvalue'), array(), array());
+ $this->assertEmpty($request->getValue('myvar'));
+
+ $request = new Request($this->container, array(), array(), array('myvar' => 'myvalue', 'csrf_token' => $this->container['token']->getCSRFToken()), array(), array());
+ $this->assertEquals('myvalue', $request->getValue('myvar'));
+
+ $request = new Request($this->container, array(), array(), array('myvar' => 'myvalue', 'csrf_token' => $this->container['token']->getCSRFToken()), array(), array());
+ $this->assertEquals(array('myvar' => 'myvalue'), $request->getValues());
+ }
+
+ public function testGetFileContent()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getFileContent('myfile'));
+
+ $filename = tempnam(sys_get_temp_dir(), 'UnitTest');
+ file_put_contents($filename, 'something');
+
+ $request = new Request($this->container, array(), array(), array(), array('myfile' => array('tmp_name' => $filename)), array());
+ $this->assertEquals('something', $request->getFileContent('myfile'));
+
+ unlink($filename);
+ }
+
+ public function testGetFilePath()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getFilePath('myfile'));
+
+ $request = new Request($this->container, array(), array(), array(), array('myfile' => array('tmp_name' => 'somewhere')), array());
+ $this->assertEquals('somewhere', $request->getFilePath('myfile'));
+ }
+
+ public function testIsPost()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertFalse($request->isPost());
+
+ $request = new Request($this->container, array('REQUEST_METHOD' => 'POST'), array(), array(), array(), array());
+ $this->assertTrue($request->isPost());
+ }
+
+ public function testIsAjax()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertFalse($request->isAjax());
+
+ $request = new Request($this->container, array('HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'), array(), array(), array(), array());
+ $this->assertTrue($request->isAjax());
+ }
+
+ public function testIsHTTPS()
+ {
+ $request = new Request($this->container, array(), array(), array(), array());
+ $this->assertFalse($request->isHTTPS());
+
+ $request = new Request($this->container, array('HTTPS' => ''), array(), array(), array(), array());
+ $this->assertFalse($request->isHTTPS());
+
+ $request = new Request($this->container, array('HTTPS' => 'off'), array(), array(), array(), array());
+ $this->assertFalse($request->isHTTPS());
+
+ $request = new Request($this->container, array('HTTPS' => 'on'), array(), array(), array(), array());
+ $this->assertTrue($request->isHTTPS());
+
+ $request = new Request($this->container, array('HTTPS' => '1'), array(), array(), array(), array());
+ $this->assertTrue($request->isHTTPS());
+ }
+
+ public function testGetCookie()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getCookie('mycookie'));
+
+ $request = new Request($this->container, array(), array(), array(), array(), array('mycookie' => 'miam'));
+ $this->assertEquals('miam', $request->getCookie('mycookie'));
+ }
+
+ public function testGetHeader()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getHeader('X-Forwarded-For'));
+
+ $request = new Request($this->container, array('HTTP_X_FORWARDED_FOR' => 'test'), array(), array(), array(), array());
+ $this->assertEquals('test', $request->getHeader('X-Forwarded-For'));
+ }
+
+ public function testGetRemoteUser()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getRemoteUser());
+
+ $request = new Request($this->container, array(REVERSE_PROXY_USER_HEADER => 'test'), array(), array(), array(), array());
+ $this->assertEquals('test', $request->getRemoteUser());
+ }
+
+ public function testGetQueryString()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getQueryString());
+
+ $request = new Request($this->container, array('QUERY_STRING' => 'k=v'), array(), array(), array(), array());
+ $this->assertEquals('k=v', $request->getQueryString());
+ }
+
+ public function testGetUri()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEmpty($request->getUri());
+
+ $request = new Request($this->container, array('REQUEST_URI' => '/blah'), array(), array(), array(), array());
+ $this->assertEquals('/blah', $request->getUri());
+ }
+
+ public function testGetUserAgent()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEquals('Unknown', $request->getUserAgent());
+
+ $request = new Request($this->container, array('HTTP_USER_AGENT' => 'My browser'), array(), array(), array(), array());
+ $this->assertEquals('My browser', $request->getUserAgent());
+ }
+
+ public function testGetIpAddress()
+ {
+ $request = new Request($this->container, array(), array(), array(), array(), array());
+ $this->assertEquals('Unknown', $request->getIpAddress());
+
+ $request = new Request($this->container, array('HTTP_X_FORWARDED_FOR' => '192.168.0.1,127.0.0.1'), array(), array(), array(), array());
+ $this->assertEquals('192.168.0.1', $request->getIpAddress());
+
+ $request = new Request($this->container, array('REMOTE_ADDR' => '192.168.0.1'), array(), array(), array(), array());
+ $this->assertEquals('192.168.0.1', $request->getIpAddress());
+
+ $request = new Request($this->container, array('REMOTE_ADDR' => ''), array(), array(), array(), array());
+ $this->assertEquals('Unknown', $request->getIpAddress());
+ }
+}
diff --git a/tests/units/Core/Http/RouteTest.php b/tests/units/Core/Http/RouteTest.php
new file mode 100644
index 00000000..5e44ad00
--- /dev/null
+++ b/tests/units/Core/Http/RouteTest.php
@@ -0,0 +1,79 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Http\Route;
+
+class RouteTest extends Base
+{
+ public function testFindParams()
+ {
+ $route = new Route($this->container);
+ $route->enable();
+
+ $this->assertEquals(array('p1' => true, 'p2' => true), $route->findParams(array('something', ':p1', ':p2')));
+ $this->assertEquals(array('p1' => true), $route->findParams(array('something', ':p1', '')));
+ $this->assertEquals(array('p1' => true), $route->findParams(array('something', ':p1', 'something else')));
+ }
+
+ public function testFindRoute()
+ {
+ $route = new Route($this->container);
+ $route->enable();
+
+ $route->addRoute('/mycontroller/myaction', 'mycontroller', 'myaction');
+ $this->assertEquals(
+ array('controller' => 'mycontroller', 'action' => 'myaction', 'plugin' => ''),
+ $route->findRoute('/mycontroller/myaction')
+ );
+
+ $route->addRoute('/a/b/c', 'mycontroller', 'myaction', 'myplugin');
+ $this->assertEquals(
+ array('controller' => 'mycontroller', 'action' => 'myaction', 'plugin' => 'myplugin'),
+ $route->findRoute('/a/b/c')
+ );
+
+ $this->assertEquals(
+ array('controller' => 'app', 'action' => 'index', 'plugin' => ''),
+ $route->findRoute('/notfound')
+ );
+
+ $route->addRoute('/a/b/:c', 'mycontroller', 'myaction', 'myplugin');
+ $this->assertEquals(
+ array('controller' => 'mycontroller', 'action' => 'myaction', 'plugin' => 'myplugin'),
+ $route->findRoute('/a/b/myvalue')
+ );
+
+ $this->assertEquals('myvalue', $this->container['request']->getStringParam('c'));
+
+ $route->addRoute('/a/:p1/b/:p2', 'mycontroller', 'myaction');
+ $this->assertEquals(
+ array('controller' => 'mycontroller', 'action' => 'myaction', 'plugin' => ''),
+ $route->findRoute('/a/v1/b/v2')
+ );
+
+ $this->assertEquals('v1', $this->container['request']->getStringParam('p1'));
+ $this->assertEquals('v2', $this->container['request']->getStringParam('p2'));
+ }
+
+ public function testFindUrl()
+ {
+ $route = new Route($this->container);
+ $route->enable();
+ $route->addRoute('a/b', 'controller1', 'action1');
+ $route->addRoute('a/:myvar1/b/:myvar2', 'controller2', 'action2');
+ $route->addRoute('/something', 'controller1', 'action1', 'myplugin');
+ $route->addRoute('/myplugin/myroute', 'controller1', 'action2', 'myplugin');
+ $route->addRoute('/foo/:myvar', 'controller1', 'action3', 'myplugin');
+
+ $this->assertEquals('a/1/b/2', $route->findUrl('controller2', 'action2', array('myvar1' => 1, 'myvar2' => 2)));
+ $this->assertEquals('', $route->findUrl('controller2', 'action2', array('myvar1' => 1)));
+ $this->assertEquals('a/b', $route->findUrl('controller1', 'action1'));
+ $this->assertEquals('', $route->findUrl('controller1', 'action2'));
+
+ $this->assertEquals('myplugin/myroute', $route->findUrl('controller1', 'action2', array(), 'myplugin'));
+ $this->assertEquals('something', $route->findUrl('controller1', 'action1', array(), 'myplugin'));
+ $this->assertEquals('foo/123', $route->findUrl('controller1', 'action3', array('myvar' => 123), 'myplugin'));
+ $this->assertEquals('foo/123', $route->findUrl('controller1', 'action3', array('myvar' => 123, 'plugin' => 'myplugin')));
+ }
+}
diff --git a/tests/units/Core/Http/RouterTest.php b/tests/units/Core/Http/RouterTest.php
new file mode 100644
index 00000000..0b200ab5
--- /dev/null
+++ b/tests/units/Core/Http/RouterTest.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Kanboard\Controller {
+
+ class FakeController {
+ public function beforeAction() {}
+ public function myAction() {}
+ }
+}
+
+namespace Kanboard\Plugin\Myplugin\Controller {
+
+ class FakeController {
+ public function beforeAction() {}
+ public function myAction() {}
+ }
+}
+
+namespace {
+
+ require_once __DIR__.'/../../Base.php';
+
+ use Kanboard\Core\Helper;
+ use Kanboard\Core\Http\Route;
+ use Kanboard\Core\Http\Router;
+ use Kanboard\Core\Http\Request;
+
+ class RouterTest extends Base
+ {
+ public function testSanitize()
+ {
+ $dispatcher = new Router($this->container);
+
+ $this->assertEquals('PloP', $dispatcher->sanitize('PloP', 'default'));
+ $this->assertEquals('default', $dispatcher->sanitize('', 'default'));
+ $this->assertEquals('default', $dispatcher->sanitize('123-AB', 'default'));
+ $this->assertEquals('default', $dispatcher->sanitize('R&D', 'default'));
+ $this->assertEquals('Test123', $dispatcher->sanitize('Test123', 'default'));
+ $this->assertEquals('Test_123', $dispatcher->sanitize('Test_123', 'default'));
+ $this->assertEquals('userImport', $dispatcher->sanitize('userImport', 'default'));
+ }
+
+ public function testGetPath()
+ {
+ $dispatcher = new Router($this->container);
+
+ $this->container['helper'] = new Helper($this->container);
+ $this->container['request'] = new Request($this->container, array('PHP_SELF' => '/index.php', 'REQUEST_URI' => '/a/b/c', 'REQUEST_METHOD' => 'GET'));
+ $this->assertEquals('a/b/c', $dispatcher->getPath());
+
+ $this->container['helper'] = new Helper($this->container);
+ $this->container['request'] = new Request($this->container, array('PHP_SELF' => '/index.php', 'REQUEST_URI' => '/a/b/something?test=a', 'QUERY_STRING' => 'test=a', 'REQUEST_METHOD' => 'GET'));
+ $this->assertEquals('a/b/something', $dispatcher->getPath());
+
+ $this->container['helper'] = new Helper($this->container);
+ $this->container['request'] = new Request($this->container, array('PHP_SELF' => '/a/index.php', 'REQUEST_URI' => '/a/b/something?test=a', 'QUERY_STRING' => 'test=a', 'REQUEST_METHOD' => 'GET'));
+ $this->assertEquals('b/something', $dispatcher->getPath());
+ }
+
+ public function testDispatcherWithControllerNotFound()
+ {
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_URI' => '/kanboard/?controller=FakeControllerNotFound&action=myAction&myvar=value1',
+ 'QUERY_STRING' => 'controller=FakeControllerNotFound&action=myAction&myvar=value1',
+ 'REQUEST_METHOD' => 'GET'
+ ),
+ array(
+ 'controller' => 'FakeControllerNotFound',
+ 'action' => 'myAction',
+ 'myvar' => 'value1',
+ )
+ );
+
+ $this->setExpectedException('RuntimeException', 'Controller not found');
+
+ $dispatcher = new Router($this->container);
+ $dispatcher->dispatch();
+ }
+
+ public function testDispatcherWithActionNotFound()
+ {
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_URI' => '/kanboard/?controller=FakeController&action=myActionNotFound&myvar=value1',
+ 'QUERY_STRING' => 'controller=FakeController&action=myActionNotFound&myvar=value1',
+ 'REQUEST_METHOD' => 'GET'
+ ),
+ array(
+ 'controller' => 'FakeController',
+ 'action' => 'myActionNotFound',
+ 'myvar' => 'value1',
+ )
+ );
+
+ $this->setExpectedException('RuntimeException', 'Action not implemented');
+
+ $dispatcher = new Router($this->container);
+ $dispatcher->dispatch();
+ }
+
+ public function testDispatcherWithNoUrlRewrite()
+ {
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_URI' => '/kanboard/?controller=FakeController&action=myAction&myvar=value1',
+ 'QUERY_STRING' => 'controller=FakeController&action=myAction&myvar=value1',
+ 'REQUEST_METHOD' => 'GET'
+ ),
+ array(
+ 'controller' => 'FakeController',
+ 'action' => 'myAction',
+ 'myvar' => 'value1',
+ )
+ );
+
+ $dispatcher = new Router($this->container);
+ $this->assertInstanceOf('\Kanboard\Controller\FakeController', $dispatcher->dispatch());
+ $this->assertEquals('FakeController', $dispatcher->getController());
+ $this->assertEquals('myAction', $dispatcher->getAction());
+ $this->assertEquals('', $dispatcher->getPlugin());
+ $this->assertEquals('value1', $this->container['request']->getStringParam('myvar'));
+ }
+
+ public function testDispatcherWithNoUrlRewriteAndPlugin()
+ {
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_URI' => '/kanboard/?controller=FakeController&action=myAction&myvar=value1&plugin=myplugin',
+ 'QUERY_STRING' => 'controller=FakeController&action=myAction&myvar=value1&plugin=myplugin',
+ 'REQUEST_METHOD' => 'GET'
+ ),
+ array(
+ 'controller' => 'FakeController',
+ 'action' => 'myAction',
+ 'myvar' => 'value1',
+ 'plugin' => 'myplugin',
+ )
+ );
+
+ $dispatcher = new Router($this->container);
+ $this->assertInstanceOf('\Kanboard\Plugin\Myplugin\Controller\FakeController', $dispatcher->dispatch());
+ $this->assertEquals('FakeController', $dispatcher->getController());
+ $this->assertEquals('myAction', $dispatcher->getAction());
+ $this->assertEquals('Myplugin', $dispatcher->getPlugin());
+ $this->assertEquals('value1', $this->container['request']->getStringParam('myvar'));
+ }
+
+ public function testDispatcherWithUrlRewrite()
+ {
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_URI' => '/kanboard/my/route/123?myvar=value1',
+ 'QUERY_STRING' => 'myvar=value1',
+ 'REQUEST_METHOD' => 'GET'
+ ),
+ array(
+ 'myvar' => 'value1',
+ )
+ );
+
+ $this->container['route'] = new Route($this->container);
+ $this->container['route']->enable();
+ $dispatcher = new Router($this->container);
+
+ $this->container['route']->addRoute('/my/route/:param', 'FakeController', 'myAction');
+
+ $this->assertInstanceOf('\Kanboard\Controller\FakeController', $dispatcher->dispatch());
+ $this->assertEquals('FakeController', $dispatcher->getController());
+ $this->assertEquals('myAction', $dispatcher->getAction());
+ $this->assertEquals('', $dispatcher->getPlugin());
+ $this->assertEquals('value1', $this->container['request']->getStringParam('myvar'));
+ $this->assertEquals('123', $this->container['request']->getStringParam('param'));
+ }
+
+ public function testDispatcherWithUrlRewriteWithPlugin()
+ {
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_URI' => '/kanboard/my/plugin/route/123?myvar=value1',
+ 'QUERY_STRING' => 'myvar=value1',
+ 'REQUEST_METHOD' => 'GET'
+ ),
+ array(
+ 'myvar' => 'value1',
+ )
+ );
+
+ $this->container['route'] = new Route($this->container);
+ $this->container['route']->enable();
+ $dispatcher = new Router($this->container);
+
+ $this->container['route']->addRoute('/my/plugin/route/:param', 'fakeController', 'myAction', 'Myplugin');
+
+ $this->assertInstanceOf('\Kanboard\Plugin\Myplugin\Controller\FakeController', $dispatcher->dispatch());
+ $this->assertEquals('FakeController', $dispatcher->getController());
+ $this->assertEquals('myAction', $dispatcher->getAction());
+ $this->assertEquals('Myplugin', $dispatcher->getPlugin());
+ $this->assertEquals('value1', $this->container['request']->getStringParam('myvar'));
+ $this->assertEquals('123', $this->container['request']->getStringParam('param'));
+ }
+ }
+}
diff --git a/tests/units/Core/Ldap/ClientTest.php b/tests/units/Core/Ldap/ClientTest.php
new file mode 100644
index 00000000..d149500e
--- /dev/null
+++ b/tests/units/Core/Ldap/ClientTest.php
@@ -0,0 +1,220 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+require_once __DIR__.'/../../Base.php';
+
+function ldap_connect($hostname, $port)
+{
+ return ClientTest::$functions->ldap_connect($hostname, $port);
+}
+
+function ldap_set_option()
+{
+}
+
+function ldap_bind($link_identifier, $bind_rdn = null, $bind_password = null)
+{
+ return ClientTest::$functions->ldap_bind($link_identifier, $bind_rdn, $bind_password);
+}
+
+function ldap_start_tls($link_identifier)
+{
+ return ClientTest::$functions->ldap_start_tls($link_identifier);
+}
+
+class ClientTest extends \Base
+{
+ public static $functions;
+ private $ldap;
+
+ public function setUp()
+ {
+ parent::setup();
+
+ self::$functions = $this
+ ->getMockBuilder('stdClass')
+ ->setMethods(array(
+ 'ldap_connect',
+ 'ldap_set_option',
+ 'ldap_bind',
+ 'ldap_start_tls',
+ ))
+ ->getMock();
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+ self::$functions = null;
+ }
+
+ public function testGetLdapServerNotConfigured()
+ {
+ $this->setExpectedException('\LogicException');
+ $ldap = new Client;
+ $ldap->getLdapServer();
+ }
+
+ public function testConnectSuccess()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_connect')
+ ->with(
+ $this->equalTo('my_ldap_server'),
+ $this->equalTo(389)
+ )
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $ldap = new Client;
+ $ldap->open('my_ldap_server');
+ $this->assertEquals('my_ldap_resource', $ldap->getConnection());
+ }
+
+ public function testConnectFailure()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_connect')
+ ->with(
+ $this->equalTo('my_ldap_server'),
+ $this->equalTo(389)
+ )
+ ->will($this->returnValue(false));
+
+ $this->setExpectedException('\Kanboard\Core\Ldap\ClientException');
+
+ $ldap = new Client;
+ $ldap->open('my_ldap_server');
+ $this->assertNotEquals('my_ldap_resource', $ldap->getConnection());
+ }
+
+ public function testConnectSuccessWithTLS()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_connect')
+ ->with(
+ $this->equalTo('my_ldap_server'),
+ $this->equalTo(389)
+ )
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_start_tls')
+ ->with(
+ $this->equalTo('my_ldap_resource')
+ )
+ ->will($this->returnValue(true));
+
+ $ldap = new Client;
+ $ldap->open('my_ldap_server', 389, true);
+ $this->assertEquals('my_ldap_resource', $ldap->getConnection());
+ }
+
+ public function testConnectFailureWithTLS()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_connect')
+ ->with(
+ $this->equalTo('my_ldap_server'),
+ $this->equalTo(389)
+ )
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_start_tls')
+ ->with(
+ $this->equalTo('my_ldap_resource')
+ )
+ ->will($this->returnValue(false));
+
+ $this->setExpectedException('\Kanboard\Core\Ldap\ClientException');
+
+ $ldap = new Client;
+ $ldap->open('my_ldap_server', 389, true);
+ $this->assertNotEquals('my_ldap_resource', $ldap->getConnection());
+ }
+
+ public function testAnonymousAuthenticationSuccess()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_bind')
+ ->will($this->returnValue(true));
+
+ $ldap = new Client;
+ $this->assertTrue($ldap->useAnonymousAuthentication());
+ }
+
+ public function testAnonymousAuthenticationFailure()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_bind')
+ ->will($this->returnValue(false));
+
+ $this->setExpectedException('\Kanboard\Core\Ldap\ClientException');
+
+ $ldap = new Client;
+ $ldap->useAnonymousAuthentication();
+ }
+
+ public function testUserAuthenticationSuccess()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_connect')
+ ->with(
+ $this->equalTo('my_ldap_server'),
+ $this->equalTo(389)
+ )
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_bind')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('my_ldap_user'),
+ $this->equalTo('my_ldap_password')
+ )
+ ->will($this->returnValue(true));
+
+ $ldap = new Client;
+ $ldap->open('my_ldap_server');
+ $this->assertTrue($ldap->authenticate('my_ldap_user', 'my_ldap_password'));
+ }
+
+ public function testUserAuthenticationFailure()
+ {
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_connect')
+ ->with(
+ $this->equalTo('my_ldap_server'),
+ $this->equalTo(389)
+ )
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_bind')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('my_ldap_user'),
+ $this->equalTo('my_ldap_password')
+ )
+ ->will($this->returnValue(false));
+
+ $this->setExpectedException('\Kanboard\Core\Ldap\ClientException');
+
+ $ldap = new Client;
+ $ldap->open('my_ldap_server');
+ $ldap->authenticate('my_ldap_user', 'my_ldap_password');
+ }
+}
diff --git a/tests/units/Core/Ldap/EntriesTest.php b/tests/units/Core/Ldap/EntriesTest.php
new file mode 100644
index 00000000..65025b6e
--- /dev/null
+++ b/tests/units/Core/Ldap/EntriesTest.php
@@ -0,0 +1,55 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Ldap\Entries;
+
+class EntriesTest extends Base
+{
+ private $entries = array(
+ 'count' => 2,
+ 0 => array(
+ 'cn' => array(
+ 'count' => 1,
+ 0 => 'Kanboard Other Group',
+ ),
+ 0 => 'cn',
+ 'count' => 1,
+ 'dn' => 'CN=Kanboard Other Group,CN=Users,DC=kanboard,DC=local',
+ ),
+ 1 => array(
+ 'cn' => array(
+ 'count' => 1,
+ 0 => 'Kanboard Users',
+ ),
+ 0 => 'cn',
+ 'count' => 1,
+ 'dn' => 'CN=Kanboard Users,CN=Users,DC=kanboard,DC=local',
+ ),
+ );
+
+ public function testGetAll()
+ {
+ $entries = new Entries(array());
+ $this->assertEmpty($entries->getAll());
+
+ $entries = new Entries($this->entries);
+ $result = $entries->getAll();
+ $this->assertCount(2, $result);
+ $this->assertInstanceOf('Kanboard\Core\Ldap\Entry', $result[0]);
+ $this->assertEquals('CN=Kanboard Users,CN=Users,DC=kanboard,DC=local', $result[1]->getDn());
+ $this->assertEquals('Kanboard Users', $result[1]->getFirstValue('cn'));
+ }
+
+ public function testGetFirst()
+ {
+ $entries = new Entries(array());
+ $this->assertEquals('', $entries->getFirstEntry()->getDn());
+
+ $entries = new Entries($this->entries);
+ $result = $entries->getFirstEntry();
+ $this->assertInstanceOf('Kanboard\Core\Ldap\Entry', $result);
+ $this->assertEquals('CN=Kanboard Other Group,CN=Users,DC=kanboard,DC=local', $result->getDn());
+ $this->assertEquals('Kanboard Other Group', $result->getFirstValue('cn'));
+ }
+}
diff --git a/tests/units/Core/Ldap/EntryTest.php b/tests/units/Core/Ldap/EntryTest.php
new file mode 100644
index 00000000..45585e77
--- /dev/null
+++ b/tests/units/Core/Ldap/EntryTest.php
@@ -0,0 +1,71 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Ldap\Entry;
+
+class EntryTest extends Base
+{
+ private $entry = array(
+ 'count' => 2,
+ 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local',
+ 'displayname' => array(
+ 'count' => 1,
+ 0 => 'My LDAP user',
+ ),
+ 'broken' => array(
+ ),
+ 'mail' => array(
+ 'count' => 2,
+ 0 => 'user1@localhost',
+ 1 => 'user2@localhost',
+ ),
+ 'samaccountname' => array(
+ 'count' => 1,
+ 0 => 'my_ldap_user',
+ ),
+ 0 => 'displayname',
+ 1 => 'mail',
+ 2 => 'samaccountname',
+ );
+
+ public function testGetAll()
+ {
+ $expected = array(
+ 'user1@localhost',
+ 'user2@localhost',
+ );
+
+ $entry = new Entry($this->entry);
+ $this->assertEquals($expected, $entry->getAll('mail'));
+ $this->assertEmpty($entry->getAll('not found'));
+ $this->assertEmpty($entry->getAll('broken'));
+ }
+
+ public function testGetFirst()
+ {
+ $entry = new Entry($this->entry);
+ $this->assertEquals('user1@localhost', $entry->getFirstValue('mail'));
+ $this->assertEquals('', $entry->getFirstValue('not found'));
+ $this->assertEquals('default', $entry->getFirstValue('not found', 'default'));
+ $this->assertEquals('default', $entry->getFirstValue('broken', 'default'));
+ }
+
+ public function testGetDn()
+ {
+ $entry = new Entry($this->entry);
+ $this->assertEquals('uid=my_user,ou=People,dc=kanboard,dc=local', $entry->getDn());
+
+ $entry = new Entry(array());
+ $this->assertEquals('', $entry->getDn());
+ }
+
+ public function testHasValue()
+ {
+ $entry = new Entry($this->entry);
+ $this->assertTrue($entry->hasValue('mail', 'user2@localhost'));
+ $this->assertFalse($entry->hasValue('mail', 'user3@localhost'));
+ $this->assertTrue($entry->hasValue('displayname', 'My LDAP user'));
+ $this->assertFalse($entry->hasValue('displayname', 'Something else'));
+ }
+}
diff --git a/tests/units/Core/Ldap/LdapGroupTest.php b/tests/units/Core/Ldap/LdapGroupTest.php
new file mode 100644
index 00000000..3f538249
--- /dev/null
+++ b/tests/units/Core/Ldap/LdapGroupTest.php
@@ -0,0 +1,160 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Ldap\Group;
+use Kanboard\Core\Ldap\Entries;
+use Kanboard\Core\Security\Role;
+
+class LdapGroupTest extends Base
+{
+ private $query;
+ private $client;
+ private $group;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->client = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\Client')
+ ->setMethods(array(
+ 'getConnection',
+ ))
+ ->getMock();
+
+ $this->query = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\Query')
+ ->setConstructorArgs(array($this->client))
+ ->setMethods(array(
+ 'execute',
+ 'hasResult',
+ 'getEntries',
+ ))
+ ->getMock();
+
+ $this->group = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\Group')
+ ->setConstructorArgs(array($this->query))
+ ->setMethods(array(
+ 'getAttributeName',
+ 'getBasDn',
+ ))
+ ->getMock();
+ }
+
+ public function testGetGroups()
+ {
+ $entries = new Entries(array(
+ 'count' => 2,
+ 0 => array(
+ 'cn' => array(
+ 'count' => 1,
+ 0 => 'Kanboard Other Group',
+ ),
+ 0 => 'cn',
+ 'count' => 1,
+ 'dn' => 'CN=Kanboard Other Group,CN=Users,DC=kanboard,DC=local',
+ ),
+ 1 => array(
+ 'cn' => array(
+ 'count' => 1,
+ 0 => 'Kanboard Users',
+ ),
+ 0 => 'cn',
+ 'count' => 1,
+ 'dn' => 'CN=Kanboard Users,CN=Users,DC=kanboard,DC=local',
+ ),
+ ));
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $this->query
+ ->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo('CN=Users,DC=kanboard,DC=local'),
+ $this->equalTo('(&(objectClass=group)(sAMAccountName=Kanboard*))')
+ );
+
+ $this->query
+ ->expects($this->once())
+ ->method('hasResult')
+ ->will($this->returnValue(true));
+
+ $this->query
+ ->expects($this->once())
+ ->method('getEntries')
+ ->will($this->returnValue($entries));
+
+ $this->group
+ ->expects($this->any())
+ ->method('getAttributeName')
+ ->will($this->returnValue('cn'));
+
+ $this->group
+ ->expects($this->any())
+ ->method('getBasDn')
+ ->will($this->returnValue('CN=Users,DC=kanboard,DC=local'));
+
+ $groups = $this->group->find('(&(objectClass=group)(sAMAccountName=Kanboard*))');
+ $this->assertCount(2, $groups);
+ $this->assertInstanceOf('Kanboard\Group\LdapGroupProvider', $groups[0]);
+ $this->assertInstanceOf('Kanboard\Group\LdapGroupProvider', $groups[1]);
+ $this->assertEquals('Kanboard Other Group', $groups[0]->getName());
+ $this->assertEquals('Kanboard Users', $groups[1]->getName());
+ $this->assertEquals('CN=Kanboard Other Group,CN=Users,DC=kanboard,DC=local', $groups[0]->getExternalId());
+ $this->assertEquals('CN=Kanboard Users,CN=Users,DC=kanboard,DC=local', $groups[1]->getExternalId());
+ }
+
+ public function testGetGroupsWithNoResult()
+ {
+ $entries = new Entries(array());
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $this->query
+ ->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo('CN=Users,DC=kanboard,DC=local'),
+ $this->equalTo('(&(objectClass=group)(sAMAccountName=Kanboard*))')
+ );
+
+ $this->query
+ ->expects($this->once())
+ ->method('hasResult')
+ ->will($this->returnValue(false));
+
+ $this->query
+ ->expects($this->never())
+ ->method('getEntries');
+
+ $this->group
+ ->expects($this->any())
+ ->method('getAttributeName')
+ ->will($this->returnValue('cn'));
+
+ $this->group
+ ->expects($this->any())
+ ->method('getBasDn')
+ ->will($this->returnValue('CN=Users,DC=kanboard,DC=local'));
+
+ $groups = $this->group->find('(&(objectClass=group)(sAMAccountName=Kanboard*))');
+ $this->assertCount(0, $groups);
+ }
+
+ public function testGetBaseDnNotConfigured()
+ {
+ $this->setExpectedException('\LogicException');
+
+ $group = new Group($this->query);
+ $group->getBasDn();
+ }
+}
diff --git a/tests/units/Core/Ldap/LdapUserTest.php b/tests/units/Core/Ldap/LdapUserTest.php
new file mode 100644
index 00000000..2b3db1e5
--- /dev/null
+++ b/tests/units/Core/Ldap/LdapUserTest.php
@@ -0,0 +1,379 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Ldap\User;
+use Kanboard\Core\Ldap\Entries;
+use Kanboard\Core\Security\Role;
+
+class LdapUserTest extends Base
+{
+ private $query;
+ private $client;
+ private $user;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->client = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\Client')
+ ->setMethods(array(
+ 'getConnection',
+ ))
+ ->getMock();
+
+ $this->query = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\Query')
+ ->setConstructorArgs(array($this->client))
+ ->setMethods(array(
+ 'execute',
+ 'hasResult',
+ 'getEntries',
+ ))
+ ->getMock();
+
+ $this->user = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\User')
+ ->setConstructorArgs(array($this->query))
+ ->setMethods(array(
+ 'getAttributeUsername',
+ 'getAttributeEmail',
+ 'getAttributeName',
+ 'getAttributeGroup',
+ 'getGroupAdminDn',
+ 'getGroupManagerDn',
+ 'getBasDn',
+ ))
+ ->getMock();
+ }
+
+ public function testGetUser()
+ {
+ $entries = new Entries(array(
+ 'count' => 1,
+ 0 => array(
+ 'count' => 2,
+ 'dn' => 'uid=my_ldap_user,ou=People,dc=kanboard,dc=local',
+ 'displayname' => array(
+ 'count' => 1,
+ 0 => 'My LDAP user',
+ ),
+ 'mail' => array(
+ 'count' => 2,
+ 0 => 'user1@localhost',
+ 1 => 'user2@localhost',
+ ),
+ 'samaccountname' => array(
+ 'count' => 1,
+ 0 => 'my_ldap_user',
+ ),
+ 0 => 'displayname',
+ 1 => 'mail',
+ 2 => 'samaccountname',
+ )
+ ));
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $this->query
+ ->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('(uid=my_ldap_user)')
+ );
+
+ $this->query
+ ->expects($this->once())
+ ->method('hasResult')
+ ->will($this->returnValue(true));
+
+ $this->query
+ ->expects($this->once())
+ ->method('getEntries')
+ ->will($this->returnValue($entries));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeUsername')
+ ->will($this->returnValue('samaccountname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeName')
+ ->will($this->returnValue('displayname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeEmail')
+ ->will($this->returnValue('mail'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getBasDn')
+ ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
+
+ $user = $this->user->find('(uid=my_ldap_user)');
+ $this->assertInstanceOf('Kanboard\User\LdapUserProvider', $user);
+ $this->assertEquals('uid=my_ldap_user,ou=People,dc=kanboard,dc=local', $user->getDn());
+ $this->assertEquals('my_ldap_user', $user->getUsername());
+ $this->assertEquals('My LDAP user', $user->getName());
+ $this->assertEquals('user1@localhost', $user->getEmail());
+ $this->assertEquals(Role::APP_USER, $user->getRole());
+ $this->assertEquals(array(), $user->getExternalGroupIds());
+ $this->assertEquals(array('is_ldap_user' => 1), $user->getExtraAttributes());
+ }
+
+ public function testGetUserWithAdminRole()
+ {
+ $entries = new Entries(array(
+ 'count' => 1,
+ 0 => array(
+ 'count' => 2,
+ 'dn' => 'uid=my_ldap_user,ou=People,dc=kanboard,dc=local',
+ 'displayname' => array(
+ 'count' => 1,
+ 0 => 'My LDAP user',
+ ),
+ 'mail' => array(
+ 'count' => 2,
+ 0 => 'user1@localhost',
+ 1 => 'user2@localhost',
+ ),
+ 'samaccountname' => array(
+ 'count' => 1,
+ 0 => 'my_ldap_user',
+ ),
+ 'memberof' => array(
+ 'count' => 1,
+ 0 => 'CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local',
+ ),
+ 0 => 'displayname',
+ 1 => 'mail',
+ 2 => 'samaccountname',
+ 3 => 'memberof',
+ )
+ ));
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $this->query
+ ->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('(uid=my_ldap_user)')
+ );
+
+ $this->query
+ ->expects($this->once())
+ ->method('hasResult')
+ ->will($this->returnValue(true));
+
+ $this->query
+ ->expects($this->once())
+ ->method('getEntries')
+ ->will($this->returnValue($entries));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeUsername')
+ ->will($this->returnValue('samaccountname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeName')
+ ->will($this->returnValue('displayname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeEmail')
+ ->will($this->returnValue('mail'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeGroup')
+ ->will($this->returnValue('memberof'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getGroupAdminDn')
+ ->will($this->returnValue('CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getBasDn')
+ ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
+
+ $user = $this->user->find('(uid=my_ldap_user)');
+ $this->assertInstanceOf('Kanboard\User\LdapUserProvider', $user);
+ $this->assertEquals('uid=my_ldap_user,ou=People,dc=kanboard,dc=local', $user->getDn());
+ $this->assertEquals('my_ldap_user', $user->getUsername());
+ $this->assertEquals('My LDAP user', $user->getName());
+ $this->assertEquals('user1@localhost', $user->getEmail());
+ $this->assertEquals(Role::APP_ADMIN, $user->getRole());
+ $this->assertEquals(array('CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local'), $user->getExternalGroupIds());
+ $this->assertEquals(array('is_ldap_user' => 1), $user->getExtraAttributes());
+ }
+
+ public function testGetUserWithManagerRole()
+ {
+ $entries = new Entries(array(
+ 'count' => 1,
+ 0 => array(
+ 'count' => 2,
+ 'dn' => 'uid=my_ldap_user,ou=People,dc=kanboard,dc=local',
+ 'displayname' => array(
+ 'count' => 1,
+ 0 => 'My LDAP user',
+ ),
+ 'mail' => array(
+ 'count' => 2,
+ 0 => 'user1@localhost',
+ 1 => 'user2@localhost',
+ ),
+ 'samaccountname' => array(
+ 'count' => 1,
+ 0 => 'my_ldap_user',
+ ),
+ 'memberof' => array(
+ 'count' => 2,
+ 0 => 'CN=Kanboard-Users,CN=Users,DC=kanboard,DC=local',
+ 1 => 'CN=Kanboard-Managers,CN=Users,DC=kanboard,DC=local',
+ ),
+ 0 => 'displayname',
+ 1 => 'mail',
+ 2 => 'samaccountname',
+ 3 => 'memberof',
+ )
+ ));
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $this->query
+ ->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('(uid=my_ldap_user)')
+ );
+
+ $this->query
+ ->expects($this->once())
+ ->method('hasResult')
+ ->will($this->returnValue(true));
+
+ $this->query
+ ->expects($this->once())
+ ->method('getEntries')
+ ->will($this->returnValue($entries));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeUsername')
+ ->will($this->returnValue('samaccountname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeName')
+ ->will($this->returnValue('displayname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeEmail')
+ ->will($this->returnValue('mail'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeGroup')
+ ->will($this->returnValue('memberof'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getGroupManagerDn')
+ ->will($this->returnValue('CN=Kanboard-Managers,CN=Users,DC=kanboard,DC=local'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getBasDn')
+ ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
+
+ $user = $this->user->find('(uid=my_ldap_user)');
+ $this->assertInstanceOf('Kanboard\User\LdapUserProvider', $user);
+ $this->assertEquals('uid=my_ldap_user,ou=People,dc=kanboard,dc=local', $user->getDn());
+ $this->assertEquals('my_ldap_user', $user->getUsername());
+ $this->assertEquals('My LDAP user', $user->getName());
+ $this->assertEquals('user1@localhost', $user->getEmail());
+ $this->assertEquals(Role::APP_MANAGER, $user->getRole());
+ $this->assertEquals(array('CN=Kanboard-Users,CN=Users,DC=kanboard,DC=local', 'CN=Kanboard-Managers,CN=Users,DC=kanboard,DC=local'), $user->getExternalGroupIds());
+ $this->assertEquals(array('is_ldap_user' => 1), $user->getExtraAttributes());
+ }
+
+ public function testGetUserNotFound()
+ {
+ $entries = new Entries(array());
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ $this->query
+ ->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('(uid=my_ldap_user)')
+ );
+
+ $this->query
+ ->expects($this->once())
+ ->method('hasResult')
+ ->will($this->returnValue(false));
+
+ $this->query
+ ->expects($this->never())
+ ->method('getEntries');
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeUsername')
+ ->will($this->returnValue('samaccountname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeName')
+ ->will($this->returnValue('displayname'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getAttributeEmail')
+ ->will($this->returnValue('mail'));
+
+ $this->user
+ ->expects($this->any())
+ ->method('getBasDn')
+ ->will($this->returnValue('ou=People,dc=kanboard,dc=local'));
+
+ $user = $this->user->find('(uid=my_ldap_user)');
+ $this->assertEquals(null, $user);
+ }
+
+ public function testGetBaseDnNotConfigured()
+ {
+ $this->setExpectedException('\LogicException');
+
+ $user = new User($this->query);
+ $user->getBasDn();
+ }
+}
diff --git a/tests/units/Core/Ldap/QueryTest.php b/tests/units/Core/Ldap/QueryTest.php
new file mode 100644
index 00000000..b3987df0
--- /dev/null
+++ b/tests/units/Core/Ldap/QueryTest.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Kanboard\Core\Ldap;
+
+require_once __DIR__.'/../../Base.php';
+
+function ldap_search($link_identifier, $base_dn, $filter, array $attributes)
+{
+ return QueryTest::$functions->ldap_search($link_identifier, $base_dn, $filter, $attributes);
+}
+
+function ldap_get_entries($link_identifier, $result_identifier)
+{
+ return QueryTest::$functions->ldap_get_entries($link_identifier, $result_identifier);
+}
+
+class QueryTest extends \Base
+{
+ public static $functions;
+ private $client;
+
+ public function setUp()
+ {
+ parent::setup();
+
+ self::$functions = $this
+ ->getMockBuilder('stdClass')
+ ->setMethods(array(
+ 'ldap_search',
+ 'ldap_get_entries',
+ ))
+ ->getMock();
+
+ $this->client = $this
+ ->getMockBuilder('\Kanboard\Core\Ldap\Client')
+ ->setMethods(array(
+ 'getConnection',
+ ))
+ ->getMock();
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+ self::$functions = null;
+ }
+
+ public function testExecuteQuerySuccessfully()
+ {
+ $entries = array(
+ 'count' => 1,
+ 0 => array(
+ 'count' => 2,
+ 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local',
+ 'displayname' => array(
+ 'count' => 1,
+ 0 => 'My user',
+ ),
+ 'mail' => array(
+ 'count' => 2,
+ 0 => 'user1@localhost',
+ 1 => 'user2@localhost',
+ ),
+ 0 => 'displayname',
+ 1 => 'mail',
+ )
+ );
+
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_search')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('uid=my_user'),
+ $this->equalTo(array('displayname'))
+ )
+ ->will($this->returnValue('search_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_get_entries')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('search_resource')
+ )
+ ->will($this->returnValue($entries));
+
+ $query = new Query($this->client);
+ $query->execute('ou=People,dc=kanboard,dc=local', 'uid=my_user', array('displayname'));
+ $this->assertTrue($query->hasResult());
+
+ $this->assertEquals('My user', $query->getEntries()->getFirstEntry()->getFirstValue('displayname'));
+ $this->assertEquals('user1@localhost', $query->getEntries()->getFirstEntry()->getFirstValue('mail'));
+ $this->assertEquals('', $query->getEntries()->getFirstEntry()->getFirstValue('not_found'));
+
+ $this->assertEquals('uid=my_user,ou=People,dc=kanboard,dc=local', $query->getEntries()->getFirstEntry()->getDn());
+ $this->assertEquals('', $query->getEntries()->getFirstEntry()->getFirstValue('missing'));
+ }
+
+ public function testExecuteQueryNotFound()
+ {
+ $this->client
+ ->expects($this->any())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_search')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('uid=my_user'),
+ $this->equalTo(array('displayname'))
+ )
+ ->will($this->returnValue('search_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_get_entries')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('search_resource')
+ )
+ ->will($this->returnValue(array()));
+
+ $query = new Query($this->client);
+ $query->execute('ou=People,dc=kanboard,dc=local', 'uid=my_user', array('displayname'));
+ $this->assertFalse($query->hasResult());
+ }
+
+ public function testExecuteQueryFailed()
+ {
+ $this->client
+ ->expects($this->once())
+ ->method('getConnection')
+ ->will($this->returnValue('my_ldap_resource'));
+
+ self::$functions
+ ->expects($this->once())
+ ->method('ldap_search')
+ ->with(
+ $this->equalTo('my_ldap_resource'),
+ $this->equalTo('ou=People,dc=kanboard,dc=local'),
+ $this->equalTo('uid=my_user'),
+ $this->equalTo(array('displayname'))
+ )
+ ->will($this->returnValue(false));
+
+ $query = new Query($this->client);
+ $query->execute('ou=People,dc=kanboard,dc=local', 'uid=my_user', array('displayname'));
+ $this->assertFalse($query->hasResult());
+ }
+}
diff --git a/tests/units/Core/LexerTest.php b/tests/units/Core/LexerTest.php
index 9e14ff6b..55370aab 100644
--- a/tests/units/Core/LexerTest.php
+++ b/tests/units/Core/LexerTest.php
@@ -116,6 +116,31 @@ class LexerTest extends Base
);
}
+ public function testLinkQuery()
+ {
+ $lexer = new Lexer;
+
+ $this->assertEquals(
+ array(array('match' => 'link:', 'token' => 'T_LINK'), array('match' => 'is a milestone of', 'token' => 'T_STRING')),
+ $lexer->tokenize('link:"is a milestone of"')
+ );
+
+ $this->assertEquals(
+ array('T_LINK' => array('is a milestone of')),
+ $lexer->map($lexer->tokenize('link:"is a milestone of"'))
+ );
+
+ $this->assertEquals(
+ array('T_LINK' => array('is a milestone of', 'fixes')),
+ $lexer->map($lexer->tokenize('link:"is a milestone of" link:fixes'))
+ );
+
+ $this->assertEquals(
+ array(),
+ $lexer->map($lexer->tokenize('link: '))
+ );
+ }
+
public function testColumnQuery()
{
$lexer = new Lexer;
diff --git a/tests/units/Core/RouterTest.php b/tests/units/Core/RouterTest.php
deleted file mode 100644
index 753e1204..00000000
--- a/tests/units/Core/RouterTest.php
+++ /dev/null
@@ -1,81 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Core\Router;
-
-class RouterTest extends Base
-{
- public function testSanitize()
- {
- $r = new Router($this->container);
-
- $this->assertEquals('PloP', $r->sanitize('PloP', 'default'));
- $this->assertEquals('default', $r->sanitize('', 'default'));
- $this->assertEquals('default', $r->sanitize('123-AB', 'default'));
- $this->assertEquals('default', $r->sanitize('R&D', 'default'));
- $this->assertEquals('Test123', $r->sanitize('Test123', 'default'));
- $this->assertEquals('Test_123', $r->sanitize('Test_123', 'default'));
- $this->assertEquals('userImport', $r->sanitize('userImport', 'default'));
- }
-
- public function testPath()
- {
- $r = new Router($this->container);
-
- $this->assertEquals('a/b/c', $r->getPath('/a/b/c'));
- $this->assertEquals('a/b/something', $r->getPath('/a/b/something?test=a', 'test=a'));
-
- $_SERVER['REQUEST_METHOD'] = 'GET';
- $_SERVER['PHP_SELF'] = '/a/index.php';
-
- $this->assertEquals('b/c', $r->getPath('/a/b/c'));
- $this->assertEquals('b/c', $r->getPath('/a/b/c?e=f', 'e=f'));
- }
-
- public function testFindRouteWithEmptyTable()
- {
- $r = new Router($this->container);
- $this->assertEquals(array('app', 'index'), $r->findRoute(''));
- $this->assertEquals(array('app', 'index'), $r->findRoute('/'));
- }
-
- public function testFindRouteWithoutPlaceholders()
- {
- $r = new Router($this->container);
- $r->addRoute('a/b', 'controller', 'action');
- $this->assertEquals(array('app', 'index'), $r->findRoute('a/b/c'));
- $this->assertEquals(array('controller', 'action'), $r->findRoute('a/b'));
- }
-
- public function testFindRouteWithPlaceholders()
- {
- $r = new Router($this->container);
- $r->addRoute('a/:myvar1/b/:myvar2', 'controller', 'action');
- $this->assertEquals(array('app', 'index'), $r->findRoute('a/123/b'));
- $this->assertEquals(array('controller', 'action'), $r->findRoute('a/456/b/789'));
- $this->assertEquals(array('myvar1' => 456, 'myvar2' => 789), $_GET);
- }
-
- public function testFindMultipleRoutes()
- {
- $r = new Router($this->container);
- $r->addRoute('a/b', 'controller1', 'action1');
- $r->addRoute('a/b', 'duplicate', 'duplicate');
- $r->addRoute('a', 'controller2', 'action2');
- $this->assertEquals(array('controller1', 'action1'), $r->findRoute('a/b'));
- $this->assertEquals(array('controller2', 'action2'), $r->findRoute('a'));
- }
-
- public function testFindUrl()
- {
- $r = new Router($this->container);
- $r->addRoute('a/b', 'controller1', 'action1');
- $r->addRoute('a/:myvar1/b/:myvar2', 'controller2', 'action2', array('myvar1', 'myvar2'));
-
- $this->assertEquals('a/1/b/2', $r->findUrl('controller2', 'action2', array('myvar1' => 1, 'myvar2' => 2)));
- $this->assertEquals('', $r->findUrl('controller2', 'action2', array('myvar1' => 1)));
- $this->assertEquals('a/b', $r->findUrl('controller1', 'action1'));
- $this->assertEquals('', $r->findUrl('controller1', 'action2'));
- }
-}
diff --git a/tests/units/Core/Security/AccessMapTest.php b/tests/units/Core/Security/AccessMapTest.php
new file mode 100644
index 00000000..ae8044c9
--- /dev/null
+++ b/tests/units/Core/Security/AccessMapTest.php
@@ -0,0 +1,53 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Security\AccessMap;
+
+class AccessMapTest extends Base
+{
+ public function testRoleHierarchy()
+ {
+ $acl = new AccessMap;
+ $acl->setRoleHierarchy('admin', array('manager', 'user'));
+ $acl->setRoleHierarchy('manager', array('user'));
+
+ $this->assertEquals(array('admin'), $acl->getRoleHierarchy('admin'));
+ $this->assertEquals(array('manager', 'admin'), $acl->getRoleHierarchy('manager'));
+ $this->assertEquals(array('user', 'admin', 'manager'), $acl->getRoleHierarchy('user'));
+ }
+
+ public function testGetHighestRole()
+ {
+ $acl = new AccessMap;
+ $acl->setRoleHierarchy('manager', array('member', 'viewer'));
+ $acl->setRoleHierarchy('member', array('viewer'));
+
+ $this->assertEquals('manager', $acl->getHighestRole(array('viewer', 'manager', 'member')));
+ $this->assertEquals('manager', $acl->getHighestRole(array('viewer', 'manager')));
+ $this->assertEquals('manager', $acl->getHighestRole(array('manager', 'member')));
+ $this->assertEquals('member', $acl->getHighestRole(array('viewer', 'member')));
+ $this->assertEquals('member', $acl->getHighestRole(array('member')));
+ $this->assertEquals('viewer', $acl->getHighestRole(array('viewer')));
+ }
+
+ public function testAddRulesAndGetRoles()
+ {
+ $acl = new AccessMap;
+ $acl->setDefaultRole('role3');
+ $acl->setRoleHierarchy('role2', array('role1'));
+
+ $acl->add('MyController', 'myAction1', 'role2');
+ $acl->add('MyController', 'myAction2', 'role1');
+ $acl->add('MyAdminController', '*', 'role2');
+ $acl->add('SomethingElse', array('actionA', 'actionB'), 'role2');
+
+ $this->assertEquals(array('role2'), $acl->getRoles('mycontroller', 'MyAction1'));
+ $this->assertEquals(array('role1', 'role2'), $acl->getRoles('mycontroller', 'MyAction2'));
+ $this->assertEquals(array('role2'), $acl->getRoles('Myadmincontroller', 'MyAction'));
+ $this->assertEquals(array('role3'), $acl->getRoles('AnotherController', 'ActionNotFound'));
+ $this->assertEquals(array('role2'), $acl->getRoles('somethingelse', 'actiona'));
+ $this->assertEquals(array('role2'), $acl->getRoles('somethingelse', 'actionb'));
+ $this->assertEquals(array('role3'), $acl->getRoles('somethingelse', 'actionc'));
+ }
+}
diff --git a/tests/units/Core/Security/AuthenticationManagerTest.php b/tests/units/Core/Security/AuthenticationManagerTest.php
new file mode 100644
index 00000000..c2369626
--- /dev/null
+++ b/tests/units/Core/Security/AuthenticationManagerTest.php
@@ -0,0 +1,150 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Http\Request;
+use Kanboard\Core\Security\AuthenticationManager;
+use Kanboard\Auth\DatabaseAuth;
+use Kanboard\Auth\TotpAuth;
+use Kanboard\Auth\ReverseProxyAuth;
+
+class AuthenticationManagerTest extends Base
+{
+ public function testRegister()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new DatabaseAuth($this->container));
+ $provider = $authManager->getProvider('Database');
+
+ $this->assertInstanceOf('Kanboard\Core\Security\AuthenticationProviderInterface', $provider);
+ }
+
+ public function testGetProviderNotFound()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $this->setExpectedException('LogicException');
+ $authManager->getProvider('Dababase');
+ }
+
+ public function testGetPostProviderNotFound()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $this->setExpectedException('LogicException');
+ $authManager->getPostAuthenticationProvider();
+ }
+
+ public function testGetPostProvider()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new TotpAuth($this->container));
+ $provider = $authManager->getPostAuthenticationProvider();
+
+ $this->assertInstanceOf('Kanboard\Core\Security\PostAuthenticationProviderInterface', $provider);
+ }
+
+ public function testCheckSessionWhenNobodyIsLogged()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new DatabaseAuth($this->container));
+
+ $this->assertFalse($this->container['userSession']->isLogged());
+ $this->assertTrue($authManager->checkCurrentSession());
+ }
+
+ public function testCheckSessionWhenSomeoneIsLogged()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new DatabaseAuth($this->container));
+
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $this->assertTrue($this->container['userSession']->isLogged());
+ $this->assertTrue($authManager->checkCurrentSession());
+ }
+
+ public function testCheckSessionWhenNotValid()
+ {
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new DatabaseAuth($this->container));
+
+ $this->container['sessionStorage']->user = array('id' => 2);
+
+ $this->assertTrue($this->container['userSession']->isLogged());
+ $this->assertFalse($authManager->checkCurrentSession());
+ $this->assertFalse($this->container['userSession']->isLogged());
+ }
+
+ public function testPreAuthenticationSuccessful()
+ {
+ $this->container['request'] = new Request($this->container, array(REVERSE_PROXY_USER_HEADER => 'admin'));
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_SUCCESS, array($this, 'onSuccess'));
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_FAILURE, array($this, 'onFailure'));
+
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new ReverseProxyAuth($this->container));
+
+ $this->assertTrue($authManager->preAuthentication());
+
+ $called = $this->container['dispatcher']->getCalledListeners();
+ $this->assertArrayHasKey(AuthenticationManager::EVENT_SUCCESS.'.AuthenticationManagerTest::onSuccess', $called);
+ $this->assertArrayNotHasKey(AuthenticationManager::EVENT_FAILURE.'.AuthenticationManagerTest::onFailure', $called);
+ }
+
+ public function testPreAuthenticationFailed()
+ {
+ $this->container['request'] = new Request($this->container, array(REVERSE_PROXY_USER_HEADER => ''));
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_SUCCESS, array($this, 'onSuccess'));
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_FAILURE, array($this, 'onFailure'));
+
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new ReverseProxyAuth($this->container));
+
+ $this->assertFalse($authManager->preAuthentication());
+
+ $called = $this->container['dispatcher']->getCalledListeners();
+ $this->assertArrayNotHasKey(AuthenticationManager::EVENT_SUCCESS.'.AuthenticationManagerTest::onSuccess', $called);
+ $this->assertArrayNotHasKey(AuthenticationManager::EVENT_FAILURE.'.AuthenticationManagerTest::onFailure', $called);
+ }
+
+ public function testPasswordAuthenticationSuccessful()
+ {
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_SUCCESS, array($this, 'onSuccess'));
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_FAILURE, array($this, 'onFailure'));
+
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new DatabaseAuth($this->container));
+
+ $this->assertTrue($authManager->passwordAuthentication('admin', 'admin'));
+
+ $called = $this->container['dispatcher']->getCalledListeners();
+ $this->assertArrayHasKey(AuthenticationManager::EVENT_SUCCESS.'.AuthenticationManagerTest::onSuccess', $called);
+ $this->assertArrayNotHasKey(AuthenticationManager::EVENT_FAILURE.'.AuthenticationManagerTest::onFailure', $called);
+ }
+
+ public function testPasswordAuthenticationFailed()
+ {
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_SUCCESS, array($this, 'onSuccess'));
+ $this->container['dispatcher']->addListener(AuthenticationManager::EVENT_FAILURE, array($this, 'onFailure'));
+
+ $authManager = new AuthenticationManager($this->container);
+ $authManager->register(new DatabaseAuth($this->container));
+
+ $this->assertFalse($authManager->passwordAuthentication('admin', 'wrong password'));
+
+ $called = $this->container['dispatcher']->getCalledListeners();
+ $this->assertArrayNotHasKey(AuthenticationManager::EVENT_SUCCESS.'.AuthenticationManagerTest::onSuccess', $called);
+ $this->assertArrayHasKey(AuthenticationManager::EVENT_FAILURE.'.AuthenticationManagerTest::onFailure', $called);
+ }
+
+ public function onSuccess($event)
+ {
+ $this->assertInstanceOf('Kanboard\Event\AuthSuccessEvent', $event);
+ $this->assertTrue(in_array($event->getAuthType(), array('Database', 'ReverseProxy')));
+ }
+
+ public function onFailure($event)
+ {
+ $this->assertInstanceOf('Kanboard\Event\AuthFailureEvent', $event);
+ $this->assertEquals('admin', $event->getUsername());
+ }
+}
diff --git a/tests/units/Core/Security/AuthorizationTest.php b/tests/units/Core/Security/AuthorizationTest.php
new file mode 100644
index 00000000..70561ad8
--- /dev/null
+++ b/tests/units/Core/Security/AuthorizationTest.php
@@ -0,0 +1,39 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Security\Role;
+use Kanboard\Core\Security\AccessMap;
+use Kanboard\Core\Security\Authorization;
+
+class AuthorizationTest extends Base
+{
+ public function testIsAllowed()
+ {
+ $acl = new AccessMap;
+ $acl->setDefaultRole(Role::APP_USER);
+ $acl->setRoleHierarchy(Role::APP_ADMIN, array(Role::APP_MANAGER, Role::APP_USER));
+ $acl->setRoleHierarchy(Role::APP_MANAGER, array(Role::APP_USER));
+
+ $acl->add('MyController', 'myAction1', Role::APP_MANAGER);
+ $acl->add('MyController', 'myAction2', Role::APP_ADMIN);
+ $acl->add('MyManagerController', '*', Role::APP_MANAGER);
+
+ $authorization = new Authorization($acl);
+
+ $this->assertTrue($authorization->isAllowed('myController', 'myAction1', Role::APP_ADMIN));
+ $this->assertTrue($authorization->isAllowed('myController', 'myAction1', Role::APP_MANAGER));
+ $this->assertFalse($authorization->isAllowed('myController', 'myAction1', Role::APP_USER));
+ $this->assertFalse($authorization->isAllowed('myController', 'myAction1', 'something else'));
+
+ $this->assertTrue($authorization->isAllowed('MyManagerController', 'myAction', Role::APP_ADMIN));
+ $this->assertTrue($authorization->isAllowed('MyManagerController', 'myAction', Role::APP_MANAGER));
+ $this->assertFalse($authorization->isAllowed('MyManagerController', 'myAction', Role::APP_USER));
+ $this->assertFalse($authorization->isAllowed('MyManagerController', 'myAction', 'something else'));
+
+ $this->assertTrue($authorization->isAllowed('MyUserController', 'myAction', Role::APP_ADMIN));
+ $this->assertTrue($authorization->isAllowed('MyUserController', 'myAction', Role::APP_MANAGER));
+ $this->assertTrue($authorization->isAllowed('MyUserController', 'myAction', Role::APP_USER));
+ $this->assertFalse($authorization->isAllowed('MyUserController', 'myAction', 'something else'));
+ }
+}
diff --git a/tests/units/Core/Security/TokenTest.php b/tests/units/Core/Security/TokenTest.php
new file mode 100644
index 00000000..dbb7bd1a
--- /dev/null
+++ b/tests/units/Core/Security/TokenTest.php
@@ -0,0 +1,29 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Security\Token;
+
+class TokenTest extends Base
+{
+ public function testGenerateToken()
+ {
+ $t1 = Token::getToken();
+ $t2 = Token::getToken();
+
+ $this->assertNotEmpty($t1);
+ $this->assertNotEmpty($t2);
+
+ $this->assertNotEquals($t1, $t2);
+ }
+
+ public function testCSRFTokens()
+ {
+ $token = new Token($this->container);
+ $t1 = $token->getCSRFToken();
+
+ $this->assertNotEmpty($t1);
+ $this->assertTrue($token->validateCSRFToken($t1));
+ $this->assertFalse($token->validateCSRFToken($t1));
+ }
+}
diff --git a/tests/units/Core/Session/FlashMessageTest.php b/tests/units/Core/Session/FlashMessageTest.php
new file mode 100644
index 00000000..25361343
--- /dev/null
+++ b/tests/units/Core/Session/FlashMessageTest.php
@@ -0,0 +1,23 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Session\FlashMessage;
+
+class FlashMessageTest extends Base
+{
+ public function testMessage()
+ {
+ $flash = new FlashMessage($this->container);
+
+ $flash->success('my message');
+ $this->assertEquals('my message', $flash->getMessage('success'));
+ $this->assertEmpty($flash->getMessage('success'));
+
+ $flash->failure('my error message');
+ $this->assertEquals('my error message', $flash->getMessage('failure'));
+ $this->assertEmpty($flash->getMessage('failure'));
+
+ $this->assertEmpty($flash->getMessage('not found'));
+ }
+}
diff --git a/tests/units/Core/Session/SessionStorageTest.php b/tests/units/Core/Session/SessionStorageTest.php
new file mode 100644
index 00000000..dd0040d5
--- /dev/null
+++ b/tests/units/Core/Session/SessionStorageTest.php
@@ -0,0 +1,60 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Session\SessionStorage;
+
+class SessionStorageTest extends Base
+{
+ public function testNotPersistentStorage()
+ {
+ $storage = new SessionStorage();
+ $storage->something = array('a' => 'b');
+ $this->assertEquals(array('a' => 'b'), $storage->something);
+ $this->assertTrue(isset($storage->something));
+ $this->assertFalse(isset($storage->something->x));
+ $this->assertFalse(isset($storage->notFound));
+ $this->assertFalse(isset($storage->notFound->x));
+ $this->assertFalse(isset($storage->notFound['x']));
+ }
+
+ public function testPersistentStorage()
+ {
+ $session = array('d' => 'e');
+
+ $storage = new SessionStorage();
+ $storage->setStorage($session);
+ $storage->something = array('a' => 'b');
+
+ $this->assertEquals(array('a' => 'b'), $storage->something);
+ $this->assertEquals('e', $storage->d);
+
+ $storage->something['a'] = 'c';
+ $this->assertEquals('c', $storage->something['a']);
+
+ $storage = null;
+ $this->assertEquals(array('something' => array('a' => 'c'), 'd' => 'e'), $session);
+ }
+
+ public function testFlush()
+ {
+ $session = array('d' => 'e');
+
+ $storage = new SessionStorage();
+ $storage->setStorage($session);
+ $storage->something = array('a' => 'b');
+
+ $this->assertEquals(array('a' => 'b'), $storage->something);
+ $this->assertEquals('e', $storage->d);
+
+ $storage->flush();
+
+ $this->assertFalse(isset($storage->d));
+ $this->assertFalse(isset($storage->something));
+
+ $storage->foo = 'bar';
+
+ $storage = null;
+ $this->assertEquals(array('foo' => 'bar'), $session);
+ }
+}
diff --git a/tests/units/Core/User/GroupSyncTest.php b/tests/units/Core/User/GroupSyncTest.php
new file mode 100644
index 00000000..e22b86d4
--- /dev/null
+++ b/tests/units/Core/User/GroupSyncTest.php
@@ -0,0 +1,30 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\User\GroupSync;
+use Kanboard\Model\Group;
+use Kanboard\Model\GroupMember;
+
+class GroupSyncTest extends Base
+{
+ public function testSynchronize()
+ {
+ $group = new Group($this->container);
+ $groupMember = new GroupMember($this->container);
+ $groupSync = new GroupSync($this->container);
+
+ $this->assertEquals(1, $group->create('My Group 1', 'externalId1'));
+ $this->assertEquals(2, $group->create('My Group 2', 'externalId2'));
+
+ $this->assertTrue($groupMember->addUser(1, 1));
+
+ $this->assertTrue($groupMember->isMember(1, 1));
+ $this->assertFalse($groupMember->isMember(2, 1));
+
+ $groupSync->synchronize(1, array('externalId1', 'externalId2', 'externalId3'));
+
+ $this->assertTrue($groupMember->isMember(1, 1));
+ $this->assertTrue($groupMember->isMember(2, 1));
+ }
+}
diff --git a/tests/units/Core/User/UserProfileTest.php b/tests/units/Core/User/UserProfileTest.php
new file mode 100644
index 00000000..4886a945
--- /dev/null
+++ b/tests/units/Core/User/UserProfileTest.php
@@ -0,0 +1,63 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Security\Role;
+use Kanboard\Core\User\UserProfile;
+use Kanboard\User\LdapUserProvider;
+use Kanboard\User\DatabaseUserProvider;
+
+class UserProfileTest extends Base
+{
+ public function testInitializeLocalUser()
+ {
+ $userProfile = new UserProfile($this->container);
+ $user = new DatabaseUserProvider(array('id' => 1));
+
+ $this->assertTrue($userProfile->initialize($user));
+ $this->assertNotEmpty($this->container['sessionStorage']->user);
+ $this->assertEquals('admin', $this->container['sessionStorage']->user['username']);
+ }
+
+ public function testInitializeLocalUserNotFound()
+ {
+ $userProfile = new UserProfile($this->container);
+ $user = new DatabaseUserProvider(array('id' => 2));
+
+ $this->assertFalse($userProfile->initialize($user));
+ $this->assertFalse(isset($this->container['sessionStorage']->user));
+ }
+
+ public function testInitializeRemoteUser()
+ {
+ $userProfile = new UserProfile($this->container);
+ $user = new LdapUserProvider('ldapId', 'bob', 'Bob', '', Role::APP_MANAGER, array());
+
+ $this->assertTrue($userProfile->initialize($user));
+ $this->assertNotEmpty($this->container['sessionStorage']->user);
+ $this->assertEquals(2, $this->container['sessionStorage']->user['id']);
+ $this->assertEquals('bob', $this->container['sessionStorage']->user['username']);
+ $this->assertEquals(Role::APP_MANAGER, $this->container['sessionStorage']->user['role']);
+
+ $user = new LdapUserProvider('ldapId', 'bob', 'Bob', '', Role::APP_MANAGER, array());
+
+ $this->assertTrue($userProfile->initialize($user));
+ $this->assertNotEmpty($this->container['sessionStorage']->user);
+ $this->assertEquals(2, $this->container['sessionStorage']->user['id']);
+ $this->assertEquals('bob', $this->container['sessionStorage']->user['username']);
+ }
+
+ public function testAssignRemoteUser()
+ {
+ $userProfile = new UserProfile($this->container);
+ $user = new LdapUserProvider('ldapId', 'bob', 'Bob', '', Role::APP_MANAGER, array());
+
+ $this->assertTrue($userProfile->assign(1, $user));
+ $this->assertNotEmpty($this->container['sessionStorage']->user);
+ $this->assertEquals(1, $this->container['sessionStorage']->user['id']);
+ $this->assertEquals('admin', $this->container['sessionStorage']->user['username']);
+ $this->assertEquals('Bob', $this->container['sessionStorage']->user['name']);
+ $this->assertEquals('', $this->container['sessionStorage']->user['email']);
+ $this->assertEquals(Role::APP_ADMIN, $this->container['sessionStorage']->user['role']);
+ }
+}
diff --git a/tests/units/Core/User/UserPropertyTest.php b/tests/units/Core/User/UserPropertyTest.php
new file mode 100644
index 00000000..170eab4c
--- /dev/null
+++ b/tests/units/Core/User/UserPropertyTest.php
@@ -0,0 +1,60 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Security\Role;
+use Kanboard\Core\User\UserProperty;
+use Kanboard\User\LdapUserProvider;
+
+class UserPropertyTest extends Base
+{
+ public function testGetProperties()
+ {
+ $user = new LdapUserProvider('ldapId', 'bob', 'Bob', '', Role::APP_USER, array());
+
+ $expected = array(
+ 'username' => 'bob',
+ 'name' => 'Bob',
+ 'role' => Role::APP_USER,
+ 'is_ldap_user' => 1,
+ );
+
+ $this->assertEquals($expected, UserProperty::getProperties($user));
+
+ $user = new LdapUserProvider('ldapId', 'bob', '', '', '', array());
+
+ $expected = array(
+ 'username' => 'bob',
+ 'is_ldap_user' => 1,
+ );
+
+ $this->assertEquals($expected, UserProperty::getProperties($user));
+ }
+
+ public function testFilterProperties()
+ {
+ $profile = array(
+ 'id' => 123,
+ 'username' => 'bob',
+ 'name' => null,
+ 'email' => '',
+ 'other_column' => 'myvalue',
+ 'role' => Role::APP_ADMIN,
+ );
+
+ $properties = array(
+ 'external_id' => '456',
+ 'username' => 'bobby',
+ 'name' => 'Bobby',
+ 'email' => 'admin@localhost',
+ 'role' => '',
+ );
+
+ $expected = array(
+ 'name' => 'Bobby',
+ 'email' => 'admin@localhost',
+ );
+
+ $this->assertEquals($expected, UserProperty::filterProperties($profile, $properties));
+ }
+}
diff --git a/tests/units/Core/User/UserSessionTest.php b/tests/units/Core/User/UserSessionTest.php
new file mode 100644
index 00000000..64413f98
--- /dev/null
+++ b/tests/units/Core/User/UserSessionTest.php
@@ -0,0 +1,144 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\User\UserSession;
+use Kanboard\Core\Security\Role;
+
+class UserSessionTest extends Base
+{
+ public function testInitialize()
+ {
+ $us = new UserSession($this->container);
+
+ $user = array(
+ 'id' => '123',
+ 'username' => 'john',
+ 'password' => 'something',
+ 'twofactor_secret' => 'something else',
+ 'is_admin' => '1',
+ 'is_project_admin' => '0',
+ 'is_ldap_user' => '0',
+ 'twofactor_activated' => '0',
+ 'role' => Role::APP_MANAGER,
+ );
+
+ $us->initialize($user);
+
+ $session = $this->container['sessionStorage']->getAll();
+
+ $this->assertNotEmpty($session);
+ $this->assertEquals(123, $session['user']['id']);
+ $this->assertEquals('john', $session['user']['username']);
+ $this->assertEquals(Role::APP_MANAGER, $session['user']['role']);
+ $this->assertFalse($session['user']['is_ldap_user']);
+ $this->assertFalse($session['user']['twofactor_activated']);
+ $this->assertArrayNotHasKey('password', $session['user']);
+ $this->assertArrayNotHasKey('twofactor_secret', $session['user']);
+ $this->assertArrayNotHasKey('is_admin', $session['user']);
+ $this->assertArrayNotHasKey('is_project_admin', $session['user']);
+
+ $this->assertEquals('john', $us->getUsername());
+ }
+
+ public function testGetId()
+ {
+ $us = new UserSession($this->container);
+
+ $this->assertEquals(0, $us->getId());
+
+ $this->container['sessionStorage']->user = array('id' => 2);
+ $this->assertEquals(2, $us->getId());
+
+ $this->container['sessionStorage']->user = array('id' => '2');
+ $this->assertEquals(2, $us->getId());
+ }
+
+ public function testIsLogged()
+ {
+ $us = new UserSession($this->container);
+
+ $this->assertFalse($us->isLogged());
+
+ $this->container['sessionStorage']->user = array();
+ $this->assertFalse($us->isLogged());
+
+ $this->container['sessionStorage']->user = array('id' => 1);
+ $this->assertTrue($us->isLogged());
+ }
+
+ public function testIsAdmin()
+ {
+ $us = new UserSession($this->container);
+
+ $this->assertFalse($us->isAdmin());
+
+ $this->container['sessionStorage']->user = array('role' => Role::APP_ADMIN);
+ $this->assertTrue($us->isAdmin());
+
+ $this->container['sessionStorage']->user = array('role' => Role::APP_USER);
+ $this->assertFalse($us->isAdmin());
+
+ $this->container['sessionStorage']->user = array('role' => '');
+ $this->assertFalse($us->isAdmin());
+ }
+
+ public function testCommentSorting()
+ {
+ $us = new UserSession($this->container);
+ $this->assertEquals('ASC', $us->getCommentSorting());
+
+ $us->setCommentSorting('DESC');
+ $this->assertEquals('DESC', $us->getCommentSorting());
+ }
+
+ public function testBoardCollapseMode()
+ {
+ $us = new UserSession($this->container);
+ $this->assertFalse($us->isBoardCollapsed(2));
+
+ $us->setBoardDisplayMode(3, false);
+ $this->assertFalse($us->isBoardCollapsed(3));
+
+ $us->setBoardDisplayMode(3, true);
+ $this->assertTrue($us->isBoardCollapsed(3));
+ }
+
+ public function testFilters()
+ {
+ $us = new UserSession($this->container);
+ $this->assertEquals('status:open', $us->getFilters(1));
+
+ $us->setFilters(1, 'assignee:me');
+ $this->assertEquals('assignee:me', $us->getFilters(1));
+
+ $this->assertEquals('status:open', $us->getFilters(2));
+
+ $us->setFilters(2, 'assignee:bob');
+ $this->assertEquals('assignee:bob', $us->getFilters(2));
+ }
+
+ public function testPostAuthentication()
+ {
+ $us = new UserSession($this->container);
+ $this->assertFalse($us->isPostAuthenticationValidated());
+
+ $this->container['sessionStorage']->postAuthenticationValidated = false;
+ $this->assertFalse($us->isPostAuthenticationValidated());
+
+ $us->validatePostAuthentication();
+ $this->assertTrue($us->isPostAuthenticationValidated());
+
+ $this->container['sessionStorage']->user = array();
+ $this->assertFalse($us->hasPostAuthentication());
+
+ $this->container['sessionStorage']->user = array('twofactor_activated' => false);
+ $this->assertFalse($us->hasPostAuthentication());
+
+ $this->container['sessionStorage']->user = array('twofactor_activated' => true);
+ $this->assertTrue($us->hasPostAuthentication());
+
+ $us->disablePostAuthentication();
+ $this->assertFalse($us->hasPostAuthentication());
+ }
+}
diff --git a/tests/units/Core/User/UserSyncTest.php b/tests/units/Core/User/UserSyncTest.php
new file mode 100644
index 00000000..e7ce42b2
--- /dev/null
+++ b/tests/units/Core/User/UserSyncTest.php
@@ -0,0 +1,55 @@
+<?php
+
+require_once __DIR__.'/../../Base.php';
+
+use Kanboard\Core\Security\Role;
+use Kanboard\Core\User\UserSync;
+use Kanboard\User\LdapUserProvider;
+
+class UserSyncTest extends Base
+{
+ public function testSynchronizeNewUser()
+ {
+ $user = new LdapUserProvider('ldapId', 'bob', 'Bob', '', Role::APP_MANAGER, array());
+ $userSync = new UserSync($this->container);
+
+ $profile = array(
+ 'id' => 2,
+ 'username' => 'bob',
+ 'name' => 'Bob',
+ 'email' => '',
+ 'role' => Role::APP_MANAGER,
+ 'is_ldap_user' => 1,
+ );
+
+ $this->assertArraySubset($profile, $userSync->synchronize($user));
+ }
+
+ public function testSynchronizeExistingUser()
+ {
+ $userSync = new UserSync($this->container);
+ $user = new LdapUserProvider('ldapId', 'admin', 'Admin', 'email@localhost', Role::APP_MANAGER, array());
+
+ $profile = array(
+ 'id' => 1,
+ 'username' => 'admin',
+ 'name' => 'Admin',
+ 'email' => 'email@localhost',
+ 'role' => Role::APP_MANAGER,
+ );
+
+ $this->assertArraySubset($profile, $userSync->synchronize($user));
+
+ $user = new LdapUserProvider('ldapId', 'admin', '', '', Role::APP_ADMIN, array());
+
+ $profile = array(
+ 'id' => 1,
+ 'username' => 'admin',
+ 'name' => 'Admin',
+ 'email' => 'email@localhost',
+ 'role' => Role::APP_ADMIN,
+ );
+
+ $this->assertArraySubset($profile, $userSync->synchronize($user));
+ }
+}
diff --git a/tests/units/ExternalLink/AttachmentLinkProviderTest.php b/tests/units/ExternalLink/AttachmentLinkProviderTest.php
new file mode 100644
index 00000000..fe374664
--- /dev/null
+++ b/tests/units/ExternalLink/AttachmentLinkProviderTest.php
@@ -0,0 +1,64 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\ExternalLink\AttachmentLinkProvider;
+
+class AttachmentLinkProviderTest extends Base
+{
+ public function testGetName()
+ {
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+ $this->assertEquals('Attachment', $attachmentLinkProvider->getName());
+ }
+
+ public function testGetType()
+ {
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+ $this->assertEquals('attachment', $attachmentLinkProvider->getType());
+ }
+
+ public function testGetDependencies()
+ {
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+ $this->assertEquals(array('related' => 'Related'), $attachmentLinkProvider->getDependencies());
+ }
+
+ public function testMatch()
+ {
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+
+ $attachmentLinkProvider->setUserTextInput('http://kanboard.net/FILE.DOC');
+ $this->assertTrue($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('http://kanboard.net/folder/document.PDF');
+ $this->assertTrue($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('http://kanboard.net/archive.zip');
+ $this->assertTrue($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput(' https://kanboard.net/folder/archive.tar ');
+ $this->assertTrue($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('http:// invalid url');
+ $this->assertFalse($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('');
+ $this->assertFalse($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('http://kanboard.net/folder/document.html');
+ $this->assertFalse($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('http://kanboard.net/folder/DOC.HTML');
+ $this->assertFalse($attachmentLinkProvider->match());
+
+ $attachmentLinkProvider->setUserTextInput('http://kanboard.net/folder/document.do');
+ $this->assertFalse($attachmentLinkProvider->match());
+ }
+
+ public function testGetLink()
+ {
+ $attachmentLinkProvider = new AttachmentLinkProvider($this->container);
+ $this->assertInstanceOf('\Kanboard\ExternalLink\AttachmentLink', $attachmentLinkProvider->getLink());
+ }
+}
diff --git a/tests/units/ExternalLink/AttachmentLinkTest.php b/tests/units/ExternalLink/AttachmentLinkTest.php
new file mode 100644
index 00000000..0211869c
--- /dev/null
+++ b/tests/units/ExternalLink/AttachmentLinkTest.php
@@ -0,0 +1,18 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\ExternalLink\AttachmentLink;
+
+class AttachmentLinkTest extends Base
+{
+ public function testGetTitleFromUrl()
+ {
+ $url = 'https://kanboard.net/folder/document.pdf';
+
+ $link = new AttachmentLink($this->container);
+ $link->setUrl($url);
+ $this->assertEquals($url, $link->getUrl());
+ $this->assertEquals('document.pdf', $link->getTitle());
+ }
+}
diff --git a/tests/units/ExternalLink/WebLinkProviderTest.php b/tests/units/ExternalLink/WebLinkProviderTest.php
new file mode 100644
index 00000000..95110ed8
--- /dev/null
+++ b/tests/units/ExternalLink/WebLinkProviderTest.php
@@ -0,0 +1,52 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\ExternalLink\WebLinkProvider;
+
+class WebLinkProviderTest extends Base
+{
+ public function testGetName()
+ {
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $this->assertEquals('Web Link', $webLinkProvider->getName());
+ }
+
+ public function testGetType()
+ {
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $this->assertEquals('weblink', $webLinkProvider->getType());
+ }
+
+ public function testGetDependencies()
+ {
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $this->assertEquals(array('related' => 'Related'), $webLinkProvider->getDependencies());
+ }
+
+ public function testMatch()
+ {
+ $webLinkProvider = new WebLinkProvider($this->container);
+
+ $webLinkProvider->setUserTextInput('http://kanboard.net/');
+ $this->assertTrue($webLinkProvider->match());
+
+ $webLinkProvider->setUserTextInput('http://kanboard.net/mypage');
+ $this->assertTrue($webLinkProvider->match());
+
+ $webLinkProvider->setUserTextInput(' https://kanboard.net/ ');
+ $this->assertTrue($webLinkProvider->match());
+
+ $webLinkProvider->setUserTextInput('http:// invalid url');
+ $this->assertFalse($webLinkProvider->match());
+
+ $webLinkProvider->setUserTextInput('');
+ $this->assertFalse($webLinkProvider->match());
+ }
+
+ public function testGetLink()
+ {
+ $webLinkProvider = new WebLinkProvider($this->container);
+ $this->assertInstanceOf('\Kanboard\ExternalLink\WebLink', $webLinkProvider->getLink());
+ }
+}
diff --git a/tests/units/ExternalLink/WebLinkTest.php b/tests/units/ExternalLink/WebLinkTest.php
new file mode 100644
index 00000000..85487e15
--- /dev/null
+++ b/tests/units/ExternalLink/WebLinkTest.php
@@ -0,0 +1,45 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\ExternalLink\WebLink;
+
+class WebLinkTest extends Base
+{
+ public function testGetTitleFromHtml()
+ {
+ $url = 'http://kanboard.net/something';
+ $title = 'My title';
+ $html = '<!DOCTYPE html><html><head><title> '.$title.' </title></head><body>Test</body></html>';
+
+ $webLink = new WebLink($this->container);
+ $webLink->setUrl($url);
+ $this->assertEquals($url, $webLink->getUrl());
+
+ $this->container['httpClient']
+ ->expects($this->once())
+ ->method('get')
+ ->with($url)
+ ->will($this->returnValue($html));
+
+ $this->assertEquals($title, $webLink->getTitle());
+ }
+
+ public function testGetTitleFromUrl()
+ {
+ $url = 'http://kanboard.net/something';
+ $html = '<!DOCTYPE html><html><head></head><body>Test</body></html>';
+
+ $webLink = new WebLink($this->container);
+ $webLink->setUrl($url);
+ $this->assertEquals($url, $webLink->getUrl());
+
+ $this->container['httpClient']
+ ->expects($this->once())
+ ->method('get')
+ ->with($url)
+ ->will($this->returnValue($html));
+
+ $this->assertEquals('kanboard.net/something', $webLink->getTitle());
+ }
+}
diff --git a/tests/units/Group/LdapBackendGroupProviderTest.php b/tests/units/Group/LdapBackendGroupProviderTest.php
new file mode 100644
index 00000000..67b5ef35
--- /dev/null
+++ b/tests/units/Group/LdapBackendGroupProviderTest.php
@@ -0,0 +1,16 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Group\LdapBackendGroupProvider;
+
+class LdapBackendGroupProviderTest extends Base
+{
+ public function testGetLdapGroupPattern()
+ {
+ $this->setExpectedException('LogicException', 'LDAP group filter empty, check the parameter LDAP_GROUP_FILTER');
+
+ $backend = new LdapBackendGroupProvider($this->container);
+ $backend->getLdapGroupPattern('test');
+ }
+}
diff --git a/tests/units/Helper/AppHelperTest.php b/tests/units/Helper/AppHelperTest.php
index cbd8b8ab..0639b7aa 100644
--- a/tests/units/Helper/AppHelperTest.php
+++ b/tests/units/Helper/AppHelperTest.php
@@ -2,7 +2,7 @@
require_once __DIR__.'/../Base.php';
-use Kanboard\Core\Session;
+use Kanboard\Core\Session\FlashMessage;
use Kanboard\Helper\App;
use Kanboard\Model\Config;
@@ -23,15 +23,15 @@ class AppHelperTest extends Base
public function testFlashMessage()
{
$h = new App($this->container);
- $s = new Session;
+ $f = new FlashMessage($this->container);
$this->assertEmpty($h->flashMessage());
- $s->flash('test & test');
+
+ $f->success('test & test');
$this->assertEquals('<div class="alert alert-success alert-fade-out">test &amp; test</div>', $h->flashMessage());
$this->assertEmpty($h->flashMessage());
- $this->assertEmpty($h->flashMessage());
- $s->flashError('test & test');
+ $f->failure('test & test');
$this->assertEquals('<div class="alert alert-error">test &amp; test</div>', $h->flashMessage());
$this->assertEmpty($h->flashMessage());
}
diff --git a/tests/units/Helper/AssetHelperTest.php b/tests/units/Helper/AssetHelperTest.php
index 7e1edb12..64fcd569 100644
--- a/tests/units/Helper/AssetHelperTest.php
+++ b/tests/units/Helper/AssetHelperTest.php
@@ -15,6 +15,7 @@ class AssetHelperTest extends Base
$this->assertEmpty($h->customCss());
$this->assertTrue($c->save(array('application_stylesheet' => 'p { color: red }')));
+ $this->container['memoryCache']->flush();
$this->assertEquals('<style>p { color: red }</style>', $h->customCss());
}
diff --git a/tests/units/Helper/DatetimeHelperTest.php b/tests/units/Helper/DatetimeHelperTest.php
index 8e9c461b..f27a2eb9 100644
--- a/tests/units/Helper/DatetimeHelperTest.php
+++ b/tests/units/Helper/DatetimeHelperTest.php
@@ -6,23 +6,45 @@ use Kanboard\Helper\Dt;
class DatetimeHelperTest extends Base
{
+ public function testGetTime()
+ {
+ $helper = new Dt($this->container);
+ $this->assertEquals('17:25', $helper->time(1422206700));
+ }
+
+ public function testGetDate()
+ {
+ $helper = new Dt($this->container);
+ $this->assertEquals('01/25/2015', $helper->date(1422206700));
+ $this->assertEquals('01/25/2015', $helper->date('2015-01-25'));
+ $this->assertEquals('', $helper->date('0'));
+ $this->assertEquals('', $helper->date(0));
+ $this->assertEquals('', $helper->date(''));
+ }
+
+ public function testGetDatetime()
+ {
+ $helper = new Dt($this->container);
+ $this->assertEquals('01/25/2015 17:25', $helper->datetime(1422206700));
+ }
+
public function testAge()
{
- $h = new Dt($this->container);
-
- $this->assertEquals('&lt;15m', $h->age(0, 30));
- $this->assertEquals('&lt;30m', $h->age(0, 1000));
- $this->assertEquals('&lt;1h', $h->age(0, 3000));
- $this->assertEquals('~2h', $h->age(0, 2*3600));
- $this->assertEquals('1d', $h->age(0, 30*3600));
- $this->assertEquals('2d', $h->age(0, 65*3600));
+ $helper = new Dt($this->container);
+
+ $this->assertEquals('&lt;15m', $helper->age(0, 30));
+ $this->assertEquals('&lt;30m', $helper->age(0, 1000));
+ $this->assertEquals('&lt;1h', $helper->age(0, 3000));
+ $this->assertEquals('~2h', $helper->age(0, 2*3600));
+ $this->assertEquals('1d', $helper->age(0, 30*3600));
+ $this->assertEquals('2d', $helper->age(0, 65*3600));
}
public function testGetDayHours()
{
- $h = new Dt($this->container);
+ $helper = new Dt($this->container);
- $slots = $h->getDayHours();
+ $slots = $helper->getDayHours();
$this->assertNotEmpty($slots);
$this->assertCount(48, $slots);
@@ -36,9 +58,9 @@ class DatetimeHelperTest extends Base
public function testGetWeekDays()
{
- $h = new Dt($this->container);
+ $helper = new Dt($this->container);
- $slots = $h->getWeekDays();
+ $slots = $helper->getWeekDays();
$this->assertNotEmpty($slots);
$this->assertCount(7, $slots);
@@ -48,9 +70,9 @@ class DatetimeHelperTest extends Base
public function testGetWeekDay()
{
- $h = new Dt($this->container);
+ $helper = new Dt($this->container);
- $this->assertEquals('Monday', $h->getWeekDay(1));
- $this->assertEquals('Sunday', $h->getWeekDay(7));
+ $this->assertEquals('Monday', $helper->getWeekDay(1));
+ $this->assertEquals('Sunday', $helper->getWeekDay(7));
}
}
diff --git a/tests/units/Helper/FileHelperText.php b/tests/units/Helper/FileHelperText.php
index a681c890..6a1b78a4 100644
--- a/tests/units/Helper/FileHelperText.php
+++ b/tests/units/Helper/FileHelperText.php
@@ -8,8 +8,29 @@ class FileHelperTest extends Base
{
public function testIcon()
{
- $h = new File($this->container);
- $this->assertEquals('fa-file-image-o', $h->icon('test.png'));
- $this->assertEquals('fa-file-o', $h->icon('test'));
+ $helper = new File($this->container);
+ $this->assertEquals('fa-file-image-o', $helper->icon('test.png'));
+ $this->assertEquals('fa-file-o', $helper->icon('test'));
+ }
+
+ public function testGetMimeType()
+ {
+ $helper = new File($this->container);
+
+ $this->assertEquals('image/jpeg', $helper->getImageMimeType('My File.JPG'));
+ $this->assertEquals('image/jpeg', $helper->getImageMimeType('My File.jpeg'));
+ $this->assertEquals('image/png', $helper->getImageMimeType('My File.PNG'));
+ $this->assertEquals('image/gif', $helper->getImageMimeType('My File.gif'));
+ $this->assertEquals('image/jpeg', $helper->getImageMimeType('My File.bmp'));
+ $this->assertEquals('image/jpeg', $helper->getImageMimeType('My File'));
+ }
+
+ public function testGetPreviewType()
+ {
+ $helper = new File($this->container);
+ $this->assertEquals('text', $helper->getPreviewType('test.txt'));
+ $this->assertEquals('markdown', $helper->getPreviewType('test.markdown'));
+ $this->assertEquals('md', $helper->getPreviewType('test.md'));
+ $this->assertEquals(null, $helper->getPreviewType('test.doc'));
}
}
diff --git a/tests/units/Helper/TaskHelperTest.php b/tests/units/Helper/TaskHelperTest.php
new file mode 100644
index 00000000..726188e4
--- /dev/null
+++ b/tests/units/Helper/TaskHelperTest.php
@@ -0,0 +1,32 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Helper\Task;
+
+class TaskHelperTest extends Base
+{
+ public function testSelectPriority()
+ {
+ $helper = new Task($this->container);
+ $this->assertNotEmpty($helper->selectPriority(array('priority_end' => '3', 'priority_start' => '1', 'priority_default' => '2'), array()));
+ $this->assertEmpty($helper->selectPriority(array('priority_end' => '3', 'priority_start' => '3', 'priority_default' => '2'), array()));
+ }
+
+ public function testFormatPriority()
+ {
+ $helper = new Task($this->container);
+
+ $this->assertEquals(
+ '<span class="task-board-priority" title="Task priority">P2</span>',
+ $helper->formatPriority(array('priority_end' => '3', 'priority_start' => '1', 'priority_default' => '2'), array('priority' => 2))
+ );
+
+ $this->assertEquals(
+ '<span class="task-board-priority" title="Task priority">-P6</span>',
+ $helper->formatPriority(array('priority_end' => '3', 'priority_start' => '1', 'priority_default' => '2'), array('priority' => -6))
+ );
+
+ $this->assertEmpty($helper->formatPriority(array('priority_end' => '3', 'priority_start' => '3', 'priority_default' => '2'), array()));
+ }
+}
diff --git a/tests/units/Helper/TextHelperTest.php b/tests/units/Helper/TextHelperTest.php
index 30c537a9..a4bdfa91 100644
--- a/tests/units/Helper/TextHelperTest.php
+++ b/tests/units/Helper/TextHelperTest.php
@@ -6,7 +6,7 @@ use Kanboard\Helper\Text;
class TextHelperTest extends Base
{
- public function testMarkdown()
+ public function testMarkdownTaskLink()
{
$h = new Text($this->container);
@@ -31,6 +31,12 @@ class TextHelperTest extends Base
);
}
+ public function testMarkdownUserLink()
+ {
+ $h = new Text($this->container);
+ $this->assertEquals('<p>Text <a href="?controller=user&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a> @notfound</p>', $h->markdown('Text @admin @notfound'));
+ }
+
public function testFormatBytes()
{
$h = new Text($this->container);
diff --git a/tests/units/Helper/UrlHelperTest.php b/tests/units/Helper/UrlHelperTest.php
index cbacbc73..15e01237 100644
--- a/tests/units/Helper/UrlHelperTest.php
+++ b/tests/units/Helper/UrlHelperTest.php
@@ -4,14 +4,36 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Helper\Url;
use Kanboard\Model\Config;
+use Kanboard\Core\Http\Request;
class UrlHelperTest extends Base
{
- public function testLink()
+ public function testPluginLink()
{
$h = new Url($this->container);
$this->assertEquals(
- '<a href="?controller=a&amp;action=b&amp;d=e" class="f" title="g" target="_blank">label</a>',
+ '<a href="?controller=a&amp;action=b&amp;d=e&amp;plugin=something" class="f" title=\'g\' target="_blank">label</a>',
+ $h->link('label', 'a', 'b', array('d' => 'e', 'plugin' => 'something'), false, 'f', 'g', true)
+ );
+ }
+
+ public function testPluginLinkWithRouteDefined()
+ {
+ $this->container['route']->enable();
+ $this->container['route']->addRoute('/myplugin/something/:d', 'a', 'b', 'something');
+
+ $h = new Url($this->container);
+ $this->assertEquals(
+ '<a href="myplugin/something/e" class="f" title=\'g\' target="_blank">label</a>',
+ $h->link('label', 'a', 'b', array('d' => 'e', 'plugin' => 'something'), false, 'f', 'g', true)
+ );
+ }
+
+ public function testAppLink()
+ {
+ $h = new Url($this->container);
+ $this->assertEquals(
+ '<a href="?controller=a&amp;action=b&amp;d=e" class="f" title=\'g\' target="_blank">label</a>',
$h->link('label', 'a', 'b', array('d' => 'e'), false, 'f', 'g', true)
);
}
@@ -36,48 +58,66 @@ class UrlHelperTest extends Base
public function testDir()
{
- $h = new Url($this->container);
- $this->assertEquals('', $h->dir());
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/kanboard/index.php',
+ 'REQUEST_METHOD' => 'GET'
+ )
+ );
- $_SERVER['REQUEST_METHOD'] = 'GET';
- $_SERVER['PHP_SELF'] = '/plop/index.php';
$h = new Url($this->container);
- $this->assertEquals('/plop/', $h->dir());
+ $this->assertEquals('/kanboard/', $h->dir());
+
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/index.php',
+ 'REQUEST_METHOD' => 'GET'
+ )
+ );
- $_SERVER['REQUEST_METHOD'] = 'GET';
- $_SERVER['PHP_SELF'] = '';
$h = new Url($this->container);
$this->assertEquals('/', $h->dir());
}
public function testServer()
{
- $h = new Url($this->container);
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/index.php',
+ 'REQUEST_METHOD' => 'GET',
+ 'SERVER_NAME' => 'localhost',
+ 'SERVER_PORT' => 80,
+ )
+ );
+ $h = new Url($this->container);
$this->assertEquals('http://localhost/', $h->server());
- $_SERVER['PHP_SELF'] = '/';
- $_SERVER['SERVER_NAME'] = 'kb';
- $_SERVER['SERVER_PORT'] = 1234;
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/index.php',
+ 'REQUEST_METHOD' => 'GET',
+ 'SERVER_NAME' => 'kb',
+ 'SERVER_PORT' => 1234,
+ )
+ );
+ $h = new Url($this->container);
$this->assertEquals('http://kb:1234/', $h->server());
}
public function testBase()
{
- $h = new Url($this->container);
-
- $this->assertEquals('http://localhost/', $h->base());
-
- $_SERVER['PHP_SELF'] = '/';
- $_SERVER['SERVER_NAME'] = 'kb';
- $_SERVER['SERVER_PORT'] = 1234;
+ $this->container['request'] = new Request($this->container, array(
+ 'PHP_SELF' => '/index.php',
+ 'REQUEST_METHOD' => 'GET',
+ 'SERVER_NAME' => 'kb',
+ 'SERVER_PORT' => 1234,
+ )
+ );
$h = new Url($this->container);
$this->assertEquals('http://kb:1234/', $h->base());
$c = new Config($this->container);
$c->save(array('application_url' => 'https://mykanboard/'));
+ $this->container['memoryCache']->flush();
$h = new Url($this->container);
$this->assertEquals('https://mykanboard/', $c->get('application_url'));
diff --git a/tests/units/Helper/UserHelperTest.php b/tests/units/Helper/UserHelperTest.php
index 4cc9fa65..f1099faa 100644
--- a/tests/units/Helper/UserHelperTest.php
+++ b/tests/units/Helper/UserHelperTest.php
@@ -4,179 +4,227 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Helper\User;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
+use Kanboard\Model\ProjectUserRole;
use Kanboard\Model\User as UserModel;
-use Kanboard\Core\Session;
+use Kanboard\Core\Security\Role;
class UserHelperTest extends Base
{
public function testInitials()
{
- $h = new User($this->container);
+ $helper = new User($this->container);
- $this->assertEquals('CN', $h->getInitials('chuck norris'));
- $this->assertEquals('A', $h->getInitials('admin'));
+ $this->assertEquals('CN', $helper->getInitials('chuck norris'));
+ $this->assertEquals('A', $helper->getInitials('admin'));
}
- public function testIsProjectAdministrationAllowedForProjectAdmin()
+ public function testGetRoleName()
{
- $h = new User($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new UserModel($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project manager
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
+ $helper = new User($this->container);
+ $this->assertEquals('Administrator', $helper->getRoleName(Role::APP_ADMIN));
+ $this->assertEquals('Manager', $helper->getRoleName(Role::APP_MANAGER));
+ $this->assertEquals('Project Viewer', $helper->getRoleName(Role::PROJECT_VIEWER));
+ }
+
+ public function testHasAccessForAdmins()
+ {
+ $helper = new User($this->container);
+
+ $this->container['sessionStorage']->user = array(
+ 'id' => 2,
+ 'role' => Role::APP_ADMIN,
+ );
+
+ $this->assertTrue($helper->hasAccess('user', 'create'));
+ $this->assertTrue($helper->hasAccess('ProjectCreation', 'create'));
+ $this->assertTrue($helper->hasAccess('ProjectCreation', 'createPrivate'));
+ }
+
+ public function testHasAccessForManagers()
+ {
+ $helper = new User($this->container);
+
+ $this->container['sessionStorage']->user = array(
+ 'id' => 2,
+ 'role' => Role::APP_MANAGER,
+ );
+
+ $this->assertFalse($helper->hasAccess('user', 'create'));
+ $this->assertTrue($helper->hasAccess('ProjectCreation', 'create'));
+ $this->assertTrue($helper->hasAccess('ProjectCreation', 'createPrivate'));
+ }
+
+ public function testHasAccessForUsers()
+ {
+ $helper = new User($this->container);
+
+ $this->container['sessionStorage']->user = array(
+ 'id' => 2,
+ 'role' => Role::APP_USER,
+ );
+
+ $this->assertFalse($helper->hasAccess('user', 'create'));
+ $this->assertFalse($helper->hasAccess('ProjectCreation', 'create'));
+ $this->assertTrue($helper->hasAccess('ProjectCreation', 'createPrivate'));
+ }
+
+ public function testHasProjectAccessForAdmins()
+ {
+ $helper = new User($this->container);
+ $project = new Project($this->container);
+
+ $this->container['sessionStorage']->user = array(
+ 'id' => 2,
+ 'role' => Role::APP_ADMIN,
+ );
+
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+
+ $this->assertTrue($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertTrue($helper->hasProjectAccess('board', 'show', 1));
+ }
+
+ public function testHasProjectAccessForManagers()
+ {
+ $helper = new User($this->container);
+ $project = new Project($this->container);
+
+ $this->container['sessionStorage']->user = array(
'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => true,
+ 'role' => Role::APP_MANAGER,
);
- $this->assertTrue($h->isProjectAdministrationAllowed(1));
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertFalse($helper->hasProjectAccess('board', 'show', 1));
}
- public function testIsProjectAdministrationAllowedForProjectMember()
+ public function testHasProjectAccessForUsers()
{
- $h = new User($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new UserModel($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project member
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
+ $helper = new User($this->container);
+ $project = new Project($this->container);
+
+ $this->container['sessionStorage']->user = array(
'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => false,
+ 'role' => Role::APP_USER,
);
- $this->assertFalse($h->isProjectAdministrationAllowed(1));
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertFalse($helper->hasProjectAccess('board', 'show', 1));
}
- public function testIsProjectAdministrationAllowedForProjectManager()
+ public function testHasProjectAccessForAppManagerAndProjectManagers()
{
- $h = new User($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new UserModel($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project member
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addManager(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertTrue($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
+ $helper = new User($this->container);
+ $user = new UserModel($this->container);
+ $project = new Project($this->container);
+ $projectUserRole = new ProjectUserRole($this->container);
+
+ $this->container['sessionStorage']->user = array(
'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => false,
+ 'role' => Role::APP_MANAGER,
);
- $this->assertFalse($h->isProjectAdministrationAllowed(1));
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $user->create(array('username' => 'user')));
+ $this->assertTrue($projectUserRole->addUser(1, 2, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertTrue($helper->hasProjectAccess('board', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('task', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('taskcreation', 'save', 1));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 2));
+ $this->assertFalse($helper->hasProjectAccess('board', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('task', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('taskcreation', 'save', 2));
}
- public function testIsProjectManagementAllowedForProjectAdmin()
+ public function testHasProjectAccessForProjectManagers()
{
- $h = new User($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new UserModel($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project manager
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
+ $helper = new User($this->container);
+ $user = new UserModel($this->container);
+ $project = new Project($this->container);
+ $projectUserRole = new ProjectUserRole($this->container);
+
+ $this->container['sessionStorage']->user = array(
'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => true,
+ 'role' => Role::APP_USER,
);
- $this->assertTrue($h->isProjectManagementAllowed(1));
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $user->create(array('username' => 'user')));
+ $this->assertTrue($projectUserRole->addUser(1, 2, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertTrue($helper->hasProjectAccess('board', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('task', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('taskcreation', 'save', 1));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 2));
+ $this->assertFalse($helper->hasProjectAccess('board', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('task', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('taskcreation', 'save', 2));
}
- public function testIsProjectManagementAllowedForProjectMember()
+ public function testHasProjectAccessForProjectMembers()
{
- $h = new User($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new UserModel($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project member
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
+ $helper = new User($this->container);
+ $user = new UserModel($this->container);
+ $project = new Project($this->container);
+ $projectUserRole = new ProjectUserRole($this->container);
+
+ $this->container['sessionStorage']->user = array(
'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => false,
+ 'role' => Role::APP_USER,
);
- $this->assertFalse($h->isProjectManagementAllowed(1));
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $user->create(array('username' => 'user')));
+ $this->assertTrue($projectUserRole->addUser(1, 2, Role::PROJECT_MEMBER));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertTrue($helper->hasProjectAccess('board', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('task', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('taskcreation', 'save', 1));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 2));
+ $this->assertFalse($helper->hasProjectAccess('board', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('task', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('taskcreation', 'save', 2));
}
- public function testIsProjectManagementAllowedForProjectManager()
+ public function testHasProjectAccessForProjectViewers()
{
- $h = new User($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new UserModel($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project member
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addManager(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertTrue($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
+ $helper = new User($this->container);
+ $user = new UserModel($this->container);
+ $project = new Project($this->container);
+ $projectUserRole = new ProjectUserRole($this->container);
+
+ $this->container['sessionStorage']->user = array(
'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => false,
+ 'role' => Role::APP_USER,
);
- $this->assertTrue($h->isProjectManagementAllowed(1));
+ $this->assertEquals(1, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $project->create(array('name' => 'My project')));
+ $this->assertEquals(2, $user->create(array('username' => 'user')));
+ $this->assertTrue($projectUserRole->addUser(1, 2, Role::PROJECT_VIEWER));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 1));
+ $this->assertTrue($helper->hasProjectAccess('board', 'show', 1));
+ $this->assertTrue($helper->hasProjectAccess('task', 'show', 1));
+ $this->assertFalse($helper->hasProjectAccess('taskcreation', 'save', 1));
+
+ $this->assertFalse($helper->hasProjectAccess('ProjectEdit', 'edit', 2));
+ $this->assertFalse($helper->hasProjectAccess('board', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('task', 'show', 2));
+ $this->assertFalse($helper->hasProjectAccess('taskcreation', 'save', 2));
}
}
diff --git a/tests/units/Integration/BitbucketWebhookTest.php b/tests/units/Integration/BitbucketWebhookTest.php
deleted file mode 100644
index 1a3b005b..00000000
--- a/tests/units/Integration/BitbucketWebhookTest.php
+++ /dev/null
@@ -1,398 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Integration\BitbucketWebhook;
-use Kanboard\Model\TaskCreation;
-use Kanboard\Model\TaskFinder;
-use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
-use Kanboard\Model\User;
-
-class BitbucketWebhookTest extends Base
-{
- public function testHandlePush()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_COMMIT, array($this, 'onCommit'));
-
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $bw = new BitbucketWebhook($this->container);
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_push.json'), true);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $bw->setProjectId(1);
-
- // No task
- $this->assertFalse($bw->handlePush($payload));
-
- // Create task with the wrong id
- $this->assertEquals(1, $tc->create(array('title' => 'test1', 'project_id' => 1)));
- $this->assertFalse($bw->handlePush($payload));
-
- // Create task with the right id
- $this->assertEquals(2, $tc->create(array('title' => 'test2', 'project_id' => 1)));
- $this->assertTrue($bw->handlePush($payload));
-
- $called = $this->container['dispatcher']->getCalledListeners();
- $this->assertArrayHasKey(BitbucketWebhook::EVENT_COMMIT.'.BitbucketWebhookTest::onCommit', $called);
- }
-
- public function testIssueOpened()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_OPENED, array($this, 'onIssueOpened'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $bw = new BitbucketWebhook($this->container);
- $bw->setProjectId(1);
-
- $this->assertNotFalse($bw->parsePayload(
- 'issue:created',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_opened.json'), true)
- ));
- }
-
- public function testCommentCreatedWithNoUser()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithNoUser'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:comment_created',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_comment_created.json'), true)
- ));
- }
-
- public function testCommentCreatedWithNotMember()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithNotMember'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'fguillot')));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:comment_created',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_comment_created.json'), true)
- ));
- }
-
- public function testCommentCreatedWithUser()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithUser'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'minicoders')));
-
- $pp = new ProjectPermission($this->container);
- $this->assertTrue($pp->addMember(1, 2));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:comment_created',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_comment_created.json'), true)
- ));
- }
-
- public function testIssueClosed()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_CLOSED, array($this, 'onIssueClosed'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_closed.json'), true)
- ));
- }
-
- public function testIssueClosedWithNoTask()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_CLOSED, array($this, 'onIssueClosed'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 42, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_closed.json'), true)
- ));
-
- $this->assertEmpty($this->container['dispatcher']->getCalledListeners());
- }
-
- public function testIssueReopened()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_REOPENED, array($this, 'onIssueReopened'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_reopened.json'), true)
- ));
- }
-
- public function testIssueReopenedWithNoTask()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_REOPENED, array($this, 'onIssueReopened'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 42, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_reopened.json'), true)
- ));
-
- $this->assertEmpty($this->container['dispatcher']->getCalledListeners());
- }
-
- public function testIssueUnassigned()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, array($this, 'onIssueUnassigned'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_unassigned.json'), true)
- ));
- }
-
- public function testIssueAssigned()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, array($this, 'onIssueAssigned'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'minicoders')));
-
- $pp = new ProjectPermission($this->container);
- $this->assertTrue($pp->addMember(1, 2));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_assigned.json'), true)
- ));
-
- $this->assertNotEmpty($this->container['dispatcher']->getCalledListeners());
- }
-
- public function testIssueAssignedWithNoPermission()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, function () {});
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'minicoders')));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_assigned.json'), true)
- ));
-
- $this->assertEmpty($this->container['dispatcher']->getCalledListeners());
- }
-
- public function testIssueAssignedWithNoUser()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, function () {});
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 1, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_assigned.json'), true)
- ));
-
- $this->assertEmpty($this->container['dispatcher']->getCalledListeners());
- }
-
- public function testIssueAssignedWithNoTask()
- {
- $this->container['dispatcher']->addListener(BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, function () {});
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 43, 'project_id' => 1)));
-
- $g = new BitbucketWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertFalse($g->parsePayload(
- 'issue:updated',
- json_decode(file_get_contents(__DIR__.'/../fixtures/bitbucket_issue_assigned.json'), true)
- ));
-
- $this->assertEmpty($this->container['dispatcher']->getCalledListeners());
- }
-
- public function onCommit($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(2, $data['task_id']);
- $this->assertEquals('test2', $data['title']);
- $this->assertEquals("Test another commit #2\n\n\n[Commit made by @Frederic Guillot on Bitbucket](https://bitbucket.org/minicoders/test-webhook/commits/824059cce7667d3f8d8780cc707391be821e0ea6)", $data['commit_comment']);
- $this->assertEquals("Test another commit #2\n", $data['commit_message']);
- $this->assertEquals('https://bitbucket.org/minicoders/test-webhook/commits/824059cce7667d3f8d8780cc707391be821e0ea6', $data['commit_url']);
- }
-
- public function onIssueOpened($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['reference']);
- $this->assertEquals('My new issue', $data['title']);
- $this->assertEquals("**test**\n\n[Bitbucket Issue](https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue)", $data['description']);
- }
-
- public function onCommentCreatedWithNoUser($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(0, $data['user_id']);
- $this->assertEquals(19176252, $data['reference']);
- $this->assertEquals("1. step1\n2. step2\n\n[By @Frederic Guillot on Bitbucket](https://bitbucket.org/minicoders/test-webhook/issue/1#comment-19176252)", $data['comment']);
- }
-
- public function onCommentCreatedWithNotMember($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(0, $data['user_id']);
- $this->assertEquals(19176252, $data['reference']);
- $this->assertEquals("1. step1\n2. step2\n\n[By @Frederic Guillot on Bitbucket](https://bitbucket.org/minicoders/test-webhook/issue/1#comment-19176252)", $data['comment']);
- }
-
- public function onCommentCreatedWithUser($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(2, $data['user_id']);
- $this->assertEquals(19176252, $data['reference']);
- $this->assertEquals("1. step1\n2. step2\n\n[By @Frederic Guillot on Bitbucket](https://bitbucket.org/minicoders/test-webhook/issue/1#comment-19176252)", $data['comment']);
- }
-
- public function onIssueClosed($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(1, $data['reference']);
- }
-
- public function onIssueReopened($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(1, $data['reference']);
- }
-
- public function onIssueAssigned($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(1, $data['reference']);
- $this->assertEquals(2, $data['owner_id']);
- }
-
- public function onIssueUnassigned($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(1, $data['reference']);
- $this->assertEquals(0, $data['owner_id']);
- }
-}
diff --git a/tests/units/Integration/GithubWebhookTest.php b/tests/units/Integration/GithubWebhookTest.php
deleted file mode 100644
index d64e783e..00000000
--- a/tests/units/Integration/GithubWebhookTest.php
+++ /dev/null
@@ -1,459 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Model\TaskCreation;
-use Kanboard\Model\TaskFinder;
-use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
-use Kanboard\Model\User;
-
-class GithubWebhookTest extends Base
-{
- public function testIssueOpened()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_OPENED, array($this, 'onIssueOpened'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_opened.json'), true)
- ));
- }
-
- public function testIssueAssigned()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, array($this, 'onIssueAssigned'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'fguillot')));
-
- $pp = new ProjectPermission($this->container);
- $this->assertTrue($pp->addMember(1, 2));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_assigned.json'), true)
- ));
- }
-
- public function testIssueAssignedWithNoExistingTask()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_assigned.json'), true);
-
- $this->assertFalse($g->handleIssueAssigned($payload['issue']));
- }
-
- public function testIssueAssignedWithNoExistingUser()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_assigned.json'), true);
-
- $this->assertFalse($g->handleIssueAssigned($payload['issue']));
- }
-
- public function testIssueAssignedWithNoMember()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'fguillot')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_assigned.json'), true);
-
- $this->assertFalse($g->handleIssueAssigned($payload['issue']));
- }
-
- public function testIssueAssignedWithMember()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'fguillot')));
-
- $pp = new ProjectPermission($this->container);
- $this->assertTrue($pp->addMember(1, 2));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_assigned.json'), true);
-
- $this->assertTrue($g->handleIssueAssigned($payload['issue']));
- }
-
- public function testIssueUnassigned()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE, array($this, 'onIssueUnassigned'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_unassigned.json'), true)
- ));
- }
-
- public function testIssueClosed()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_CLOSED, array($this, 'onIssueClosed'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_closed.json'), true)
- ));
- }
-
- public function testIssueClosedWithTaskNotFound()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_closed.json'), true);
-
- $this->assertFalse($g->handleIssueClosed($payload['issue']));
- }
-
- public function testIssueReopened()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_REOPENED, array($this, 'onIssueReopened'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_reopened.json'), true)
- ));
- }
-
- public function testIssueReopenedWithTaskNotFound()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_reopened.json'), true);
-
- $this->assertFalse($g->handleIssueReopened($payload['issue']));
- }
-
- public function testIssueLabeled()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_LABEL_CHANGE, array($this, 'onIssueLabeled'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_labeled.json'), true)
- ));
- }
-
- public function testIssueLabeledWithTaskNotFound()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_labeled.json'), true);
-
- $this->assertFalse($g->handleIssueLabeled($payload['issue'], $payload['label']));
- }
-
- public function testIssueUnLabeled()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_LABEL_CHANGE, array($this, 'onIssueUnlabeled'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issues',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_unlabeled.json'), true)
- ));
- }
-
- public function testIssueUnLabeledWithTaskNotFound()
- {
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $payload = json_decode(file_get_contents(__DIR__.'/../fixtures/github_issue_unlabeled.json'), true);
-
- $this->assertFalse($g->handleIssueUnlabeled($payload['issue'], $payload['label']));
- }
-
- public function testCommentCreatedWithNoUser()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithNoUser'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue_comment',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_comment_created.json'), true)
- ));
- }
-
- public function testCommentCreatedWithNotMember()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithNotMember'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'fguillot')));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue_comment',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_comment_created.json'), true)
- ));
- }
-
- public function testCommentCreatedWithUser()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithUser'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 3, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'fguillot')));
-
- $pp = new ProjectPermission($this->container);
- $this->assertTrue($pp->addMember(1, 2));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'issue_comment',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_comment_created.json'), true)
- ));
- }
-
- public function testPush()
- {
- $this->container['dispatcher']->addListener(GithubWebhook::EVENT_COMMIT, array($this, 'onPush'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'project_id' => 1)));
-
- $g = new GithubWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- 'push',
- json_decode(file_get_contents(__DIR__.'/../fixtures/github_push.json'), true)
- ));
- }
-
- public function onIssueOpened($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(3, $data['reference']);
- $this->assertEquals('Test Webhook', $data['title']);
- $this->assertEquals("plop\n\n[Github Issue](https://github.com/kanboardapp/webhook/issues/3)", $data['description']);
- }
-
- public function onIssueAssigned($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(3, $data['reference']);
- $this->assertEquals(2, $data['owner_id']);
- }
-
- public function onIssueUnassigned($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(3, $data['reference']);
- $this->assertEquals(0, $data['owner_id']);
- }
-
- public function onIssueClosed($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(3, $data['reference']);
- }
-
- public function onIssueReopened($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(3, $data['reference']);
- }
-
- public function onIssueLabeled($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(3, $data['reference']);
- $this->assertEquals('bug', $data['label']);
- }
-
- public function onIssueUnlabeled($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(3, $data['reference']);
- $this->assertEquals('bug', $data['label']);
- $this->assertEquals(0, $data['category_id']);
- }
-
- public function onCommentCreatedWithNoUser($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(0, $data['user_id']);
- $this->assertEquals(113834672, $data['reference']);
- $this->assertEquals("test\n\n[By @fguillot on Github](https://github.com/kanboardapp/webhook/issues/3#issuecomment-113834672)", $data['comment']);
- }
-
- public function onCommentCreatedWithNotMember($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(0, $data['user_id']);
- $this->assertEquals(113834672, $data['reference']);
- $this->assertEquals("test\n\n[By @fguillot on Github](https://github.com/kanboardapp/webhook/issues/3#issuecomment-113834672)", $data['comment']);
- }
-
- public function onCommentCreatedWithUser($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(2, $data['user_id']);
- $this->assertEquals(113834672, $data['reference']);
- $this->assertEquals("test\n\n[By @fguillot on Github](https://github.com/kanboardapp/webhook/issues/3#issuecomment-113834672)", $data['comment']);
- }
-
- public function onPush($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals('boo', $data['title']);
- $this->assertEquals("Update README to fix #1\n\n[Commit made by @fguillot on Github](https://github.com/kanboardapp/webhook/commit/98dee3e49ee7aa66ffec1f761af93da5ffd711f6)", $data['commit_comment']);
- $this->assertEquals('Update README to fix #1', $data['commit_message']);
- $this->assertEquals('https://github.com/kanboardapp/webhook/commit/98dee3e49ee7aa66ffec1f761af93da5ffd711f6', $data['commit_url']);
- }
-}
diff --git a/tests/units/Integration/GitlabWebhookTest.php b/tests/units/Integration/GitlabWebhookTest.php
deleted file mode 100644
index 6d37819b..00000000
--- a/tests/units/Integration/GitlabWebhookTest.php
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Integration\GitlabWebhook;
-use Kanboard\Model\TaskCreation;
-use Kanboard\Model\TaskFinder;
-use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
-use Kanboard\Model\User;
-
-class GitlabWebhookTest extends Base
-{
- public function testGetEventType()
- {
- $g = new GitlabWebhook($this->container);
-
- $this->assertEquals(GitlabWebhook::TYPE_PUSH, $g->getType(json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_push.json'), true)));
- $this->assertEquals(GitlabWebhook::TYPE_ISSUE, $g->getType(json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_issue_opened.json'), true)));
- $this->assertEquals(GitlabWebhook::TYPE_COMMENT, $g->getType(json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_comment_created.json'), true)));
- $this->assertEquals('', $g->getType(array()));
- }
-
- public function testHandleCommit()
- {
- $g = new GitlabWebhook($this->container);
- $p = new Project($this->container);
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $g->setProjectId(1);
-
- $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_COMMIT, array($this, 'onCommit'));
-
- $event = json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_push.json'), true);
-
- // No task
- $this->assertFalse($g->handleCommit($event['commits'][0]));
-
- // Create task with the wrong id
- $this->assertEquals(1, $tc->create(array('title' => 'test1', 'project_id' => 1)));
- $this->assertFalse($g->handleCommit($event['commits'][0]));
-
- // Create task with the right id
- $this->assertEquals(2, $tc->create(array('title' => 'test2', 'project_id' => 1)));
- $this->assertTrue($g->handleCommit($event['commits'][0]));
-
- $called = $this->container['dispatcher']->getCalledListeners();
- $this->assertArrayHasKey(GitlabWebhook::EVENT_COMMIT.'.GitlabWebhookTest::onCommit', $called);
- }
-
- public function testHandleIssueOpened()
- {
- $g = new GitlabWebhook($this->container);
- $g->setProjectId(1);
-
- $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_ISSUE_OPENED, array($this, 'onOpen'));
-
- $event = json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_issue_opened.json'), true);
- $this->assertTrue($g->handleIssueOpened($event['object_attributes']));
-
- $called = $this->container['dispatcher']->getCalledListeners();
- $this->assertArrayHasKey(GitlabWebhook::EVENT_ISSUE_OPENED.'.GitlabWebhookTest::onOpen', $called);
- }
-
- public function testHandleIssueClosed()
- {
- $g = new GitlabWebhook($this->container);
- $p = new Project($this->container);
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $g->setProjectId(1);
-
- $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_ISSUE_CLOSED, array($this, 'onClose'));
-
- $event = json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_issue_closed.json'), true);
-
- // Issue not there
- $this->assertFalse($g->handleIssueClosed($event['object_attributes']));
-
- $called = $this->container['dispatcher']->getCalledListeners();
- $this->assertEmpty($called);
-
- // Create a task with the issue reference
- $this->assertEquals(1, $tc->create(array('title' => 'A', 'project_id' => 1, 'reference' => 355691)));
- $task = $tf->getByReference(1, 355691);
- $this->assertNotEmpty($task);
-
- $task = $tf->getByReference(2, 355691);
- $this->assertEmpty($task);
-
- $this->assertTrue($g->handleIssueClosed($event['object_attributes']));
-
- $called = $this->container['dispatcher']->getCalledListeners();
- $this->assertArrayHasKey(GitlabWebhook::EVENT_ISSUE_CLOSED.'.GitlabWebhookTest::onClose', $called);
- }
-
- public function testCommentCreatedWithNoUser()
- {
- $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithNoUser'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 355691, 'project_id' => 1)));
-
- $g = new GitlabWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_comment_created.json'), true)
- ));
- }
-
- public function testCommentCreatedWithNotMember()
- {
- $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithNotMember'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 355691, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'minicoders')));
-
- $g = new GitlabWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_comment_created.json'), true)
- ));
- }
-
- public function testCommentCreatedWithUser()
- {
- $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_ISSUE_COMMENT, array($this, 'onCommentCreatedWithUser'));
-
- $p = new Project($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'foobar')));
-
- $tc = new TaskCreation($this->container);
- $this->assertEquals(1, $tc->create(array('title' => 'boo', 'reference' => 355691, 'project_id' => 1)));
-
- $u = new User($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'minicoders')));
-
- $pp = new ProjectPermission($this->container);
- $this->assertTrue($pp->addMember(1, 2));
-
- $g = new GitlabWebhook($this->container);
- $g->setProjectId(1);
-
- $this->assertNotFalse($g->parsePayload(
- json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_comment_created.json'), true)
- ));
- }
-
- public function onOpen($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(355691, $data['reference']);
- $this->assertEquals('Bug', $data['title']);
- $this->assertEquals("There is a bug somewhere.\r\n\r\nBye\n\n[Gitlab Issue](https://gitlab.com/minicoders/test-webhook/issues/1)", $data['description']);
- }
-
- public function onClose($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(355691, $data['reference']);
- }
-
- public function onCommit($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(2, $data['task_id']);
- $this->assertEquals('test2', $data['title']);
- $this->assertEquals("Fix bug #2\n\n[Commit made by @Fred on Gitlab](https://gitlab.com/minicoders/test-webhook/commit/48aafa75eef9ad253aa254b0c82c987a52ebea78)", $data['commit_comment']);
- $this->assertEquals("Fix bug #2", $data['commit_message']);
- $this->assertEquals('https://gitlab.com/minicoders/test-webhook/commit/48aafa75eef9ad253aa254b0c82c987a52ebea78', $data['commit_url']);
- }
-
- public function onCommentCreatedWithNoUser($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(0, $data['user_id']);
- $this->assertEquals(1642761, $data['reference']);
- $this->assertEquals("Super comment!\n\n[By @minicoders on Gitlab](https://gitlab.com/minicoders/test-webhook/issues/1#note_1642761)", $data['comment']);
- }
-
- public function onCommentCreatedWithNotMember($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(0, $data['user_id']);
- $this->assertEquals(1642761, $data['reference']);
- $this->assertEquals("Super comment!\n\n[By @minicoders on Gitlab](https://gitlab.com/minicoders/test-webhook/issues/1#note_1642761)", $data['comment']);
- }
-
- public function onCommentCreatedWithUser($event)
- {
- $data = $event->getAll();
- $this->assertEquals(1, $data['project_id']);
- $this->assertEquals(1, $data['task_id']);
- $this->assertEquals(2, $data['user_id']);
- $this->assertEquals(1642761, $data['reference']);
- $this->assertEquals("Super comment!\n\n[By @minicoders on Gitlab](https://gitlab.com/minicoders/test-webhook/issues/1#note_1642761)", $data['comment']);
- }
-}
diff --git a/tests/units/Model/AclTest.php b/tests/units/Model/AclTest.php
deleted file mode 100644
index 28687a5c..00000000
--- a/tests/units/Model/AclTest.php
+++ /dev/null
@@ -1,308 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Core\Session;
-use Kanboard\Model\Acl;
-use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
-use Kanboard\Model\User;
-
-class AclTest extends Base
-{
- public function testMatchAcl()
- {
- $acl_rules = array(
- 'controller1' => array('action1', 'action3'),
- 'controller3' => '*',
- 'controller5' => '-',
- 'controller6' => array(),
- 'controllera' => '*',
- );
-
- $acl = new Acl($this->container);
- $this->assertTrue($acl->matchAcl($acl_rules, 'controller1', 'aCtiOn1'));
- $this->assertTrue($acl->matchAcl($acl_rules, 'controller1', 'action1'));
- $this->assertTrue($acl->matchAcl($acl_rules, 'controller1', 'action3'));
- $this->assertFalse($acl->matchAcl($acl_rules, 'controller1', 'action2'));
- $this->assertFalse($acl->matchAcl($acl_rules, 'controller2', 'action2'));
- $this->assertFalse($acl->matchAcl($acl_rules, 'controller2', 'action3'));
- $this->assertTrue($acl->matchAcl($acl_rules, 'controller3', 'anything'));
- $this->assertFalse($acl->matchAcl($acl_rules, 'controller4', 'anything'));
- $this->assertFalse($acl->matchAcl($acl_rules, 'controller5', 'anything'));
- $this->assertFalse($acl->matchAcl($acl_rules, 'controller6', 'anything'));
- $this->assertTrue($acl->matchAcl($acl_rules, 'ControllerA', 'anything'));
- $this->assertTrue($acl->matchAcl($acl_rules, 'controllera', 'anything'));
- }
-
- public function testPublicActions()
- {
- $acl = new Acl($this->container);
- $this->assertTrue($acl->isPublicAction('task', 'readonly'));
- $this->assertTrue($acl->isPublicAction('board', 'readonly'));
- $this->assertFalse($acl->isPublicAction('board', 'show'));
- $this->assertTrue($acl->isPublicAction('feed', 'project'));
- $this->assertTrue($acl->isPublicAction('feed', 'user'));
- $this->assertTrue($acl->isPublicAction('ical', 'project'));
- $this->assertTrue($acl->isPublicAction('ical', 'user'));
- $this->assertTrue($acl->isPublicAction('oauth', 'github'));
- $this->assertTrue($acl->isPublicAction('oauth', 'google'));
- $this->assertTrue($acl->isPublicAction('auth', 'login'));
- $this->assertTrue($acl->isPublicAction('auth', 'check'));
- $this->assertTrue($acl->isPublicAction('auth', 'captcha'));
- }
-
- public function testAdminActions()
- {
- $acl = new Acl($this->container);
- $this->assertFalse($acl->isAdminAction('board', 'show'));
- $this->assertFalse($acl->isAdminAction('task', 'show'));
- $this->assertTrue($acl->isAdminAction('config', 'api'));
- $this->assertTrue($acl->isAdminAction('config', 'anything'));
- $this->assertTrue($acl->isAdminAction('config', 'anything'));
- $this->assertTrue($acl->isAdminAction('user', 'save'));
- }
-
- public function testProjectAdminActions()
- {
- $acl = new Acl($this->container);
- $this->assertFalse($acl->isProjectAdminAction('config', 'save'));
- $this->assertFalse($acl->isProjectAdminAction('user', 'index'));
- $this->assertTrue($acl->isProjectAdminAction('project', 'remove'));
- }
-
- public function testProjectManagerActions()
- {
- $acl = new Acl($this->container);
- $this->assertFalse($acl->isProjectManagerAction('board', 'readonly'));
- $this->assertFalse($acl->isProjectManagerAction('project', 'remove'));
- $this->assertFalse($acl->isProjectManagerAction('project', 'show'));
- $this->assertTrue($acl->isProjectManagerAction('project', 'disable'));
- $this->assertTrue($acl->isProjectManagerAction('category', 'index'));
- $this->assertTrue($acl->isProjectManagerAction('project', 'users'));
- $this->assertFalse($acl->isProjectManagerAction('app', 'index'));
- }
-
- public function testPageAccessNoSession()
- {
- $acl = new Acl($this->container);
- $session = new Session;
- $session = array();
-
- $this->assertFalse($acl->isAllowed('board', 'readonly'));
- $this->assertFalse($acl->isAllowed('task', 'show'));
- $this->assertFalse($acl->isAllowed('config', 'application'));
- $this->assertFalse($acl->isAllowed('project', 'users'));
- $this->assertFalse($acl->isAllowed('task', 'remove'));
- $this->assertTrue($acl->isAllowed('app', 'index'));
- }
-
- public function testPageAccessEmptySession()
- {
- $acl = new Acl($this->container);
- $session = new Session;
- $session['user'] = array();
-
- $this->assertFalse($acl->isAllowed('board', 'readonly'));
- $this->assertFalse($acl->isAllowed('task', 'show'));
- $this->assertFalse($acl->isAllowed('config', 'application'));
- $this->assertFalse($acl->isAllowed('project', 'users'));
- $this->assertFalse($acl->isAllowed('task', 'remove'));
- $this->assertTrue($acl->isAllowed('app', 'index'));
- }
-
- public function testPageAccessAdminUser()
- {
- $acl = new Acl($this->container);
- $session = new Session;
-
- $session['user'] = array(
- 'is_admin' => true,
- );
-
- $this->assertTrue($acl->isAllowed('board', 'readonly'));
- $this->assertTrue($acl->isAllowed('task', 'readonly'));
- $this->assertTrue($acl->isAllowed('webhook', 'github'));
- $this->assertTrue($acl->isAllowed('task', 'show'));
- $this->assertTrue($acl->isAllowed('task', 'update'));
- $this->assertTrue($acl->isAllowed('config', 'application'));
- $this->assertTrue($acl->isAllowed('project', 'show'));
- $this->assertTrue($acl->isAllowed('project', 'users'));
- $this->assertTrue($acl->isAllowed('project', 'remove'));
- $this->assertTrue($acl->isAllowed('category', 'edit'));
- $this->assertTrue($acl->isAllowed('task', 'remove'));
- $this->assertTrue($acl->isAllowed('app', 'index'));
- }
-
- public function testPageAccessProjectAdmin()
- {
- $acl = new Acl($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new User($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project manager
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
- 'id' => 2,
- 'is_admin' => false,
- 'is_project_admin' => true,
- );
-
- $this->assertTrue($acl->isAllowed('board', 'readonly', 1));
- $this->assertTrue($acl->isAllowed('task', 'readonly', 1));
- $this->assertTrue($acl->isAllowed('webhook', 'github', 1));
- $this->assertTrue($acl->isAllowed('task', 'show', 1));
- $this->assertFalse($acl->isAllowed('task', 'show', 2));
- $this->assertTrue($acl->isAllowed('task', 'update', 1));
- $this->assertTrue($acl->isAllowed('project', 'show', 1));
- $this->assertFalse($acl->isAllowed('config', 'application', 1));
-
- $this->assertTrue($acl->isAllowed('project', 'users', 1));
- $this->assertFalse($acl->isAllowed('project', 'users', 2));
-
- $this->assertTrue($acl->isAllowed('project', 'remove', 1));
- $this->assertFalse($acl->isAllowed('project', 'remove', 2));
-
- $this->assertTrue($acl->isAllowed('category', 'edit', 1));
- $this->assertTrue($acl->isAllowed('task', 'remove', 1));
- $this->assertTrue($acl->isAllowed('app', 'index', 1));
- }
-
- public function testPageAccessProjectManager()
- {
- $acl = new Acl($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new User($this->container);
- $session = new Session;
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as project manager
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest'), 2, true));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertTrue($pp->isManager(1, 2));
-
- // We fake a session for him
- $session['user'] = array(
- 'id' => 2,
- 'is_admin' => false,
- );
-
- $this->assertTrue($acl->isAllowed('board', 'readonly', 1));
- $this->assertTrue($acl->isAllowed('task', 'readonly', 1));
- $this->assertTrue($acl->isAllowed('webhook', 'github', 1));
- $this->assertTrue($acl->isAllowed('task', 'show', 1));
- $this->assertFalse($acl->isAllowed('task', 'show', 2));
- $this->assertTrue($acl->isAllowed('task', 'update', 1));
- $this->assertTrue($acl->isAllowed('project', 'show', 1));
- $this->assertFalse($acl->isAllowed('config', 'application', 1));
-
- $this->assertTrue($acl->isAllowed('project', 'users', 1));
- $this->assertFalse($acl->isAllowed('project', 'users', 2));
-
- $this->assertFalse($acl->isAllowed('project', 'remove', 1));
- $this->assertFalse($acl->isAllowed('project', 'remove', 2));
-
- $this->assertTrue($acl->isAllowed('category', 'edit', 1));
- $this->assertTrue($acl->isAllowed('task', 'remove', 1));
- $this->assertTrue($acl->isAllowed('app', 'index', 1));
- }
-
- public function testPageAccessMember()
- {
- $acl = new Acl($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new User($this->container);
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as member
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- $session = new Session;
-
- $session['user'] = array(
- 'id' => 2,
- 'is_admin' => false,
- );
-
- $this->assertTrue($acl->isAllowed('board', 'readonly', 1));
- $this->assertTrue($acl->isAllowed('task', 'readonly', 1));
- $this->assertTrue($acl->isAllowed('webhook', 'github', 1));
- $this->assertFalse($acl->isAllowed('board', 'show', 2));
- $this->assertTrue($acl->isAllowed('board', 'show', 1));
- $this->assertFalse($acl->isAllowed('task', 'show', 2));
- $this->assertTrue($acl->isAllowed('task', 'show', 1));
- $this->assertTrue($acl->isAllowed('task', 'update', 1));
- $this->assertTrue($acl->isAllowed('project', 'show', 1));
- $this->assertFalse($acl->isAllowed('config', 'application', 1));
- $this->assertFalse($acl->isAllowed('project', 'users', 1));
- $this->assertTrue($acl->isAllowed('task', 'remove', 1));
- $this->assertFalse($acl->isAllowed('task', 'remove', 2));
- $this->assertTrue($acl->isAllowed('app', 'index', 1));
- }
-
- public function testPageAccessNotMember()
- {
- $acl = new Acl($this->container);
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new User($this->container);
-
- // We create our user
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project and set our user as member
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
- $this->assertFalse($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- $session = new Session;
-
- $session['user'] = array(
- 'id' => 2,
- 'is_admin' => false,
- );
-
- $this->assertFalse($acl->isAllowed('board', 'show', 2));
- $this->assertFalse($acl->isAllowed('board', 'show', 1));
- $this->assertFalse($acl->isAllowed('task', 'show', 1));
- $this->assertFalse($acl->isAllowed('task', 'update', 1));
- $this->assertFalse($acl->isAllowed('project', 'show', 1));
- $this->assertFalse($acl->isAllowed('config', 'application', 1));
- $this->assertFalse($acl->isAllowed('project', 'users', 1));
- $this->assertFalse($acl->isAllowed('task', 'remove', 1));
- $this->assertTrue($acl->isAllowed('app', 'index', 1));
- }
-
- public function testExtend()
- {
- $acl = new Acl($this->container);
-
- $this->assertFalse($acl->isProjectManagerAction('plop', 'show'));
-
- $acl->extend('project_manager_acl', array('plop' => '*'));
-
- $this->assertTrue($acl->isProjectManagerAction('plop', 'show'));
- $this->assertTrue($acl->isProjectManagerAction('swimlane', 'index'));
- }
-}
diff --git a/tests/units/Model/ActionTest.php b/tests/units/Model/ActionTest.php
index 30f6b22c..ed687846 100644
--- a/tests/units/Model/ActionTest.php
+++ b/tests/units/Model/ActionTest.php
@@ -4,387 +4,506 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Model\Action;
use Kanboard\Model\Project;
-use Kanboard\Model\Board;
use Kanboard\Model\Task;
-use Kanboard\Model\TaskPosition;
-use Kanboard\Model\TaskCreation;
-use Kanboard\Model\TaskFinder;
-use Kanboard\Model\Category;
use Kanboard\Model\User;
-use Kanboard\Model\ProjectPermission;
-use Kanboard\Integration\GithubWebhook;
-use Kanboard\Integration\BitbucketWebhook;
+use Kanboard\Model\Column;
+use Kanboard\Model\Category;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Core\Security\Role;
class ActionTest extends Base
{
- public function testGetActions()
+ public function testCreate()
{
- $a = new Action($this->container);
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
- $actions = $a->getAvailableActions();
- $this->assertNotEmpty($actions);
- $this->assertEquals('Add a comment log when moving the task between columns', current($actions));
- $this->assertEquals('TaskLogMoveAnotherColumn', key($actions));
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
}
- public function testExtendActions()
+ public function testRemove()
{
- $a = new Action($this->container);
- $a->extendActions('MyClass', 'Description');
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
- $actions = $a->getAvailableActions();
- $this->assertNotEmpty($actions);
- $this->assertContains('Description', $actions);
- $this->assertArrayHasKey('MyClass', $actions);
+ $this->assertNotEmpty($actionModel->getById(1));
+ $this->assertTrue($actionModel->remove(1));
+ $this->assertEmpty($actionModel->getById(1));
}
- public function testGetEvents()
+ public function testGetById()
{
- $a = new Action($this->container);
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
- $events = $a->getAvailableEvents();
- $this->assertNotEmpty($events);
- $this->assertEquals('Bitbucket commit received', current($events));
- $this->assertEquals(BitbucketWebhook::EVENT_COMMIT, key($events));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $action = $actionModel->getById(1);
+ $this->assertNotEmpty($action);
+ $this->assertEquals(1, $action['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $action['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $action['event_name']);
+ $this->assertEquals(array('column_id' => 1, 'color_id' => 'red'), $action['params']);
}
- public function testGetCompatibleEvents()
+ public function testGetAll()
{
- $a = new Action($this->container);
- $events = $a->getCompatibleEvents('TaskAssignSpecificUser');
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- $this->assertNotEmpty($events);
- $this->assertCount(2, $events);
- $this->assertArrayHasKey(Task::EVENT_CREATE_UPDATE, $events);
- $this->assertArrayHasKey(Task::EVENT_MOVE_COLUMN, $events);
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $this->assertEquals(2, $actionModel->create(array(
+ 'project_id' => 2,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 6, 'color_id' => 'blue'),
+ )));
+
+ $actions = $actionModel->getAll();
+ $this->assertCount(2, $actions);
+
+ $this->assertEquals(1, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 1, 'color_id' => 'red'), $actions[0]['params']);
+
+ $this->assertEquals(2, $actions[1]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[1]['action_name']);
+ $this->assertEquals(Task::EVENT_MOVE_COLUMN, $actions[1]['event_name']);
+ $this->assertEquals(array('column_id' => 6, 'color_id' => 'blue'), $actions[1]['params']);
}
- public function testResolveDuplicatedParameters()
+ public function testGetAllByProject()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $a = new Action($this->container);
- $c = new Category($this->container);
- $u = new User($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'P1')));
- $this->assertEquals(2, $p->create(array('name' => 'P2')));
-
- $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1)));
-
- $this->assertEquals(2, $c->create(array('name' => 'C2', 'project_id' => 2)));
- $this->assertEquals(3, $c->create(array('name' => 'C1', 'project_id' => 2)));
-
- $this->assertEquals(2, $u->create(array('username' => 'unittest1')));
- $this->assertEquals(3, $u->create(array('username' => 'unittest2')));
-
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->addMember(1, 3));
- $this->assertTrue($pp->addMember(2, 3));
-
- // anything
- $this->assertEquals('blah', $a->resolveParameters(array('name' => 'foobar', 'value' => 'blah'), 2));
-
- // project_id
- $this->assertEquals(2, $a->resolveParameters(array('name' => 'project_id', 'value' => 'blah'), 2));
-
- // category_id
- $this->assertEquals(3, $a->resolveParameters(array('name' => 'category_id', 'value' => 1), 2));
- $this->assertFalse($a->resolveParameters(array('name' => 'category_id', 'value' => 0), 2));
- $this->assertFalse($a->resolveParameters(array('name' => 'category_id', 'value' => 5), 2));
-
- // column_id
- $this->assertFalse($a->resolveParameters(array('name' => 'column_id', 'value' => 10), 2));
- $this->assertFalse($a->resolveParameters(array('name' => 'column_id', 'value' => 0), 2));
- $this->assertEquals(5, $a->resolveParameters(array('name' => 'column_id', 'value' => 1), 2));
- $this->assertEquals(6, $a->resolveParameters(array('name' => 'dest_column_id', 'value' => 2), 2));
- $this->assertEquals(7, $a->resolveParameters(array('name' => 'dst_column_id', 'value' => 3), 2));
- $this->assertEquals(8, $a->resolveParameters(array('name' => 'src_column_id', 'value' => 4), 2));
-
- // user_id
- $this->assertFalse($a->resolveParameters(array('name' => 'user_id', 'value' => 10), 2));
- $this->assertFalse($a->resolveParameters(array('name' => 'user_id', 'value' => 0), 2));
- $this->assertFalse($a->resolveParameters(array('name' => 'user_id', 'value' => 2), 2));
- $this->assertFalse($a->resolveParameters(array('name' => 'owner_id', 'value' => 2), 2));
- $this->assertEquals(3, $a->resolveParameters(array('name' => 'user_id', 'value' => 3), 2));
- $this->assertEquals(3, $a->resolveParameters(array('name' => 'owner_id', 'value' => 3), 2));
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
+
+ $this->assertEquals(2, $actionModel->create(array(
+ 'project_id' => 2,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 6, 'color_id' => 'blue'),
+ )));
+
+ $actions = $actionModel->getAllByProject(1);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(1, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 1, 'color_id' => 'red'), $actions[0]['params']);
+
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_MOVE_COLUMN, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 6, 'color_id' => 'blue'), $actions[0]['params']);
}
- public function testDuplicateSuccess()
+ public function testGetAllByUser()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $a = new Action($this->container);
- $u = new User($this->container);
+ $projectModel = new Project($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $userModel = new User($this->container);
+ $actionModel = new Action($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'P1')));
- $this->assertEquals(2, $p->create(array('name' => 'P2')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(3, $projectModel->create(array('name' => 'test4', 'is_active' => 0)));
- $this->assertEquals(2, $u->create(array('username' => 'unittest1')));
- $this->assertEquals(3, $u->create(array('username' => 'unittest2')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->addMember(1, 3));
- $this->assertTrue($pp->addMember(2, 3));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_VIEWER));
+ $this->assertTrue($projectUserRoleModel->addUser(2, 3, Role::PROJECT_MANAGER));
+ $this->assertTrue($projectUserRoleModel->addUser(3, 3, Role::PROJECT_MANAGER));
- $this->assertEquals(1, $a->create(array(
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
- 'event_name' => Task::EVENT_CREATE_UPDATE,
- 'action_name' => 'TaskAssignSpecificUser',
- 'params' => array(
- 'column_id' => 1,
- 'user_id' => 3,
- )
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
)));
- $action = $a->getById(1);
- $this->assertNotEmpty($action);
- $this->assertNotEmpty($action['params']);
- $this->assertEquals(1, $action['project_id']);
+ $this->assertEquals(2, $actionModel->create(array(
+ 'project_id' => 2,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 6, 'color_id' => 'blue'),
+ )));
- $this->assertTrue($a->duplicate(1, 2));
+ $this->assertEquals(3, $actionModel->create(array(
+ 'project_id' => 3,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 10, 'color_id' => 'green'),
+ )));
- $action = $a->getById(2);
- $this->assertNotEmpty($action);
- $this->assertNotEmpty($action['params']);
- $this->assertEquals(2, $action['project_id']);
- $this->assertEquals(Task::EVENT_CREATE_UPDATE, $action['event_name']);
- $this->assertEquals('TaskAssignSpecificUser', $action['action_name']);
- $this->assertEquals('column_id', $action['params'][0]['name']);
- $this->assertEquals(5, $action['params'][0]['value']);
- $this->assertEquals('user_id', $action['params'][1]['name']);
- $this->assertEquals(3, $action['params'][1]['value']);
+ $actions = $actionModel->getAllByUser(1);
+ $this->assertCount(0, $actions);
+
+ $actions = $actionModel->getAllByUser(2);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(1, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 1, 'color_id' => 'red'), $actions[0]['params']);
+
+ $actions = $actionModel->getAllByUser(3);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_MOVE_COLUMN, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 6, 'color_id' => 'blue'), $actions[0]['params']);
}
- public function testDuplicateUnableToResolveParams()
+ public function testDuplicateWithColumnAndColorParameter()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $a = new Action($this->container);
- $u = new User($this->container);
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'P1')));
- $this->assertEquals(2, $p->create(array('name' => 'P2')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- $this->assertEquals(2, $u->create(array('username' => 'unittest1')));
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
- $this->assertTrue($pp->addMember(1, 2));
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 5, 'color_id' => 'red'), $actions[0]['params']);
+ }
+
+ public function testDuplicateWithColumnsParameter()
+ {
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
- $this->assertEquals(1, $a->create(array(
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
- 'event_name' => Task::EVENT_CREATE_UPDATE,
- 'action_name' => 'TaskAssignSpecificUser',
- 'params' => array(
- 'column_id' => 1,
- 'user_id' => 2,
- )
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('src_column_id' => 1, 'dst_column_id' => 2, 'dest_column_id' => 3),
)));
- $action = $a->getById(1);
- $this->assertNotEmpty($action);
- $this->assertNotEmpty($action['params']);
- $this->assertEquals(1, $action['project_id']);
- $this->assertEquals('user_id', $action['params'][1]['name']);
- $this->assertEquals(2, $action['params'][1]['value']);
+ $this->assertTrue($actionModel->duplicate(1, 2));
- $this->assertTrue($a->duplicate(1, 2));
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
- $action = $a->getById(2);
- $this->assertEmpty($action);
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $actions[0]['event_name']);
+ $this->assertEquals(array('src_column_id' => 5, 'dst_column_id' => 6, 'dest_column_id' => 7), $actions[0]['params']);
}
- public function testDuplicateMixedResults()
+ public function testDuplicateWithColumnParameterNotfound()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $a = new Action($this->container);
- $u = new User($this->container);
- $c = new Category($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'P1')));
- $this->assertEquals(2, $p->create(array('name' => 'P2')));
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+ $columnModel = new Column($this->container);
- $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1)));
- $this->assertEquals(2, $c->create(array('name' => 'C2', 'project_id' => 2)));
- $this->assertEquals(3, $c->create(array('name' => 'C1', 'project_id' => 2)));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- $this->assertEquals(2, $u->create(array('username' => 'unittest1')));
+ $this->assertTrue($columnModel->update(2, 'My unique column'));
- $this->assertTrue($pp->addMember(1, 2));
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CREATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 1, 'color_id' => 'red'),
+ )));
- $this->assertEquals(1, $a->create(array(
+ $this->assertEquals(2, $actionModel->create(array(
'project_id' => 1,
- 'event_name' => Task::EVENT_CREATE_UPDATE,
- 'action_name' => 'TaskAssignSpecificUser',
- 'params' => array(
- 'column_id' => 1,
- 'user_id' => 2,
- )
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorColumn',
+ 'params' => array('column_id' => 2, 'color_id' => 'green'),
)));
- $action = $a->getById(1);
- $this->assertNotEmpty($action);
- $this->assertNotEmpty($action['params']);
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorColumn', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 5, 'color_id' => 'red'), $actions[0]['params']);
+ }
+
+ public function testDuplicateWithProjectParameter()
+ {
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
- $this->assertEquals(2, $a->create(array(
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+ $this->assertEquals(3, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
- 'event_name' => Task::EVENT_CREATE_UPDATE,
- 'action_name' => 'TaskAssignCategoryColor',
- 'params' => array(
- 'color_id' => 'blue',
- 'category_id' => 1,
- )
+ 'event_name' => Task::EVENT_CLOSE,
+ 'action_name' => '\Kanboard\Action\TaskDuplicateAnotherProject',
+ 'params' => array('column_id' => 1, 'project_id' => 3),
)));
- $action = $a->getById(2);
- $this->assertNotEmpty($action);
- $this->assertNotEmpty($action['params']);
- $this->assertEquals('category_id', $action['params'][1]['name']);
- $this->assertEquals(1, $action['params'][1]['value']);
+ $this->assertTrue($actionModel->duplicate(1, 2));
- $actions = $a->getAllByProject(1);
- $this->assertNotEmpty($actions);
- $this->assertCount(2, $actions);
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
- $this->assertTrue($a->duplicate(1, 2));
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskDuplicateAnotherProject', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CLOSE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 5, 'project_id' => 3), $actions[0]['params']);
+ }
- $actions = $a->getAllByProject(2);
- $this->assertNotEmpty($actions);
- $this->assertCount(1, $actions);
+ public function testDuplicateWithProjectParameterIdenticalToDestination()
+ {
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
- $actions = $a->getAll();
- $this->assertNotEmpty($actions);
- $this->assertCount(3, $actions);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- $action = $a->getById($actions[2]['id']);
- $this->assertNotEmpty($action);
- $this->assertNotEmpty($action['params']);
- $this->assertEquals('color_id', $action['params'][0]['name']);
- $this->assertEquals('blue', $action['params'][0]['value']);
- $this->assertEquals('category_id', $action['params'][1]['name']);
- $this->assertEquals(3, $action['params'][1]['value']);
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_CLOSE,
+ 'action_name' => '\Kanboard\Action\TaskDuplicateAnotherProject',
+ 'params' => array('column_id' => 1, 'project_id' => 2),
+ )));
+
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(0, $actions);
}
- public function testSingleAction()
+ public function testDuplicateWithUserParameter()
{
- $tp = new TaskPosition($this->container);
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $board = new Board($this->container);
- $project = new Project($this->container);
- $action = new Action($this->container);
-
- // We create a project
- $this->assertEquals(1, $project->create(array('name' => 'unit_test')));
-
- // We create a new action
- $this->assertEquals(1, $action->create(array(
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectUserRoleModel->addUser(2, 2, Role::PROJECT_MEMBER));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
'event_name' => Task::EVENT_MOVE_COLUMN,
- 'action_name' => 'TaskClose',
- 'params' => array(
- 'column_id' => 4,
- )
+ 'action_name' => '\Kanboard\Action\TaskAssignSpecificUser',
+ 'params' => array('column_id' => 1, 'user_id' => 2),
)));
- // We create a task
- $this->assertEquals(1, $tc->create(array(
- 'title' => 'unit_test',
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignSpecificUser', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_MOVE_COLUMN, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 5, 'user_id' => 2), $actions[0]['params']);
+ }
+
+ public function testDuplicateWithUserParameterButNotAssignable()
+ {
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectUserRoleModel->addUser(2, 2, Role::PROJECT_VIEWER));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
- 'owner_id' => 1,
- 'color_id' => 'red',
- 'column_id' => 1,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignSpecificUser',
+ 'params' => array('column_id' => 1, 'user_id' => 2),
)));
- // We attach events
- $action->attachEvents();
+ $this->assertTrue($actionModel->duplicate(1, 2));
- // Our task should be open
- $t1 = $tf->getById(1);
- $this->assertEquals(1, $t1['is_active']);
- $this->assertEquals(1, $t1['column_id']);
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(0, $actions);
+ }
+
+ public function testDuplicateWithUserParameterButNotAvailable()
+ {
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
- // We move our task
- $tp->movePosition(1, 1, 4, 1);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
- // Our task should be closed
- $t1 = $tf->getById(1);
- $this->assertEquals(4, $t1['column_id']);
- $this->assertEquals(0, $t1['is_active']);
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+
+ $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+
+ $this->assertEquals(1, $actionModel->create(array(
+ 'project_id' => 1,
+ 'event_name' => Task::EVENT_MOVE_COLUMN,
+ 'action_name' => '\Kanboard\Action\TaskAssignSpecificUser',
+ 'params' => array('column_id' => 1, 'owner_id' => 2),
+ )));
+
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(0, $actions);
}
- public function testMultipleActions()
+ public function testDuplicateWithCategoryParameter()
{
- $tp = new TaskPosition($this->container);
- $tc = new TaskCreation($this->container);
- $tf = new TaskFinder($this->container);
- $b = new Board($this->container);
- $p = new Project($this->container);
- $a = new Action($this->container);
- $g = new GithubWebhook($this->container);
-
- // We create a project
- $this->assertEquals(1, $p->create(array('name' => 'unit_test')));
-
- // We create a new action
- $this->assertEquals(1, $a->create(array(
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+ $categoryModel = new Category($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+ $this->assertEquals(2, $categoryModel->create(array('name' => 'c1', 'project_id' => 2)));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
- 'event_name' => GithubWebhook::EVENT_ISSUE_OPENED,
- 'action_name' => 'TaskCreation',
- 'params' => array()
+ 'event_name' => Task::EVENT_CREATE_UPDATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorCategory',
+ 'params' => array('column_id' => 1, 'category_id' => 1),
)));
- $this->assertEquals(2, $a->create(array(
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(1, $actions);
+
+ $this->assertEquals(2, $actions[0]['project_id']);
+ $this->assertEquals('\Kanboard\Action\TaskAssignColorCategory', $actions[0]['action_name']);
+ $this->assertEquals(Task::EVENT_CREATE_UPDATE, $actions[0]['event_name']);
+ $this->assertEquals(array('column_id' => 5, 'category_id' => 2), $actions[0]['params']);
+ }
+
+ public function testDuplicateWithCategoryParameterButDifferentName()
+ {
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+ $categoryModel = new Category($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+ $this->assertEquals(2, $categoryModel->create(array('name' => 'c2', 'project_id' => 2)));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
- 'event_name' => GithubWebhook::EVENT_ISSUE_LABEL_CHANGE,
- 'action_name' => 'TaskAssignCategoryLabel',
- 'params' => array(
- 'label' => 'bug',
- 'category_id' => 1,
- )
+ 'event_name' => Task::EVENT_CREATE_UPDATE,
+ 'action_name' => '\Kanboard\Action\TaskAssignColorCategory',
+ 'params' => array('column_id' => 1, 'category_id' => 1),
)));
- $this->assertEquals(3, $a->create(array(
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(0, $actions);
+ }
+
+ public function testDuplicateWithCategoryParameterButNotFound()
+ {
+ $projectModel = new Project($this->container);
+ $actionModel = new Action($this->container);
+ $categoryModel = new Category($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'test2')));
+
+ $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1)));
+
+ $this->assertEquals(1, $actionModel->create(array(
'project_id' => 1,
'event_name' => Task::EVENT_CREATE_UPDATE,
- 'action_name' => 'TaskAssignColorCategory',
- 'params' => array(
- 'color_id' => 'red',
- 'category_id' => 1,
- )
+ 'action_name' => '\Kanboard\Action\TaskAssignColorCategory',
+ 'params' => array('column_id' => 1, 'category_id' => 1),
)));
- // We attach events
- $a->attachEvents();
- $g->setProjectId(1);
-
- // We create a Github issue
- $issue = array(
- 'number' => 123,
- 'title' => 'Bugs everywhere',
- 'body' => 'There is a bug!',
- 'html_url' => 'http://localhost/',
- );
-
- $this->assertTrue($g->handleIssueOpened($issue));
-
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['is_active']);
- $this->assertEquals(0, $task['category_id']);
- $this->assertEquals('yellow', $task['color_id']);
-
- // We assign a label to our issue
- $label = array(
- 'name' => 'bug',
- );
-
- $this->assertTrue($g->handleIssueLabeled($issue, $label));
-
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals(1, $task['is_active']);
- $this->assertEquals(1, $task['category_id']);
- $this->assertEquals('red', $task['color_id']);
+ $this->assertTrue($actionModel->duplicate(1, 2));
+
+ $actions = $actionModel->getAllByProject(2);
+ $this->assertCount(0, $actions);
}
}
diff --git a/tests/units/Model/AuthenticationTest.php b/tests/units/Model/AuthenticationTest.php
deleted file mode 100644
index 6b48affe..00000000
--- a/tests/units/Model/AuthenticationTest.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Model\User;
-use Kanboard\Model\Authentication;
-
-class AuthenticationTest extends Base
-{
- public function testHasCaptcha()
- {
- $u = new User($this->container);
- $a = new Authentication($this->container);
-
- $this->assertFalse($a->hasCaptcha('not_found'));
- $this->assertFalse($a->hasCaptcha('admin'));
-
- $this->assertTrue($u->incrementFailedLogin('admin'));
- $this->assertTrue($u->incrementFailedLogin('admin'));
- $this->assertTrue($u->incrementFailedLogin('admin'));
-
- $this->assertFalse($a->hasCaptcha('not_found'));
- $this->assertTrue($a->hasCaptcha('admin'));
- }
-
- public function testHandleFailedLogin()
- {
- $u = new User($this->container);
- $a = new Authentication($this->container);
-
- $this->assertFalse($u->isLocked('admin'));
-
- for ($i = 0; $i <= 6; $i++) {
- $a->handleFailedLogin('admin');
- }
-
- $this->assertTrue($u->isLocked('admin'));
- }
-}
diff --git a/tests/units/Model/BoardTest.php b/tests/units/Model/BoardTest.php
index 125b9962..bb0778ce 100644
--- a/tests/units/Model/BoardTest.php
+++ b/tests/units/Model/BoardTest.php
@@ -4,6 +4,7 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Model\Project;
use Kanboard\Model\Board;
+use Kanboard\Model\Column;
use Kanboard\Model\Config;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
@@ -15,12 +16,13 @@ class BoardTest extends Base
{
$p = new Project($this->container);
$b = new Board($this->container);
+ $columnModel = new Column($this->container);
$c = new Config($this->container);
// Default columns
$this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $columns = $b->getColumnsList(1);
+ $columns = $columnModel->getList(1);
$this->assertTrue(is_array($columns));
$this->assertEquals(4, count($columns));
@@ -33,10 +35,11 @@ class BoardTest extends Base
$input = ' column #1 , column #2, ';
$this->assertTrue($c->save(array('board_columns' => $input)));
+ $this->container['memoryCache']->flush();
$this->assertEquals($input, $c->get('board_columns'));
$this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
- $columns = $b->getColumnsList(2);
+ $columns = $columnModel->getList(2);
$this->assertTrue(is_array($columns));
$this->assertEquals(2, count($columns));
@@ -160,225 +163,4 @@ class BoardTest extends Base
$this->assertEquals(1, $board[1]['columns'][3]['tasks'][0]['position']);
$this->assertEquals(1, $board[1]['columns'][3]['tasks'][0]['swimlane_id']);
}
-
- public function testGetColumn()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
-
- $column = $b->getColumn(3);
- $this->assertNotEmpty($column);
- $this->assertEquals('Work in progress', $column['title']);
-
- $column = $b->getColumn(33);
- $this->assertEmpty($column);
- }
-
- public function testRemoveColumn()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $this->assertTrue($b->removeColumn(3));
- $this->assertFalse($b->removeColumn(322));
-
- $columns = $b->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals(3, count($columns));
- }
-
- public function testUpdateColumn()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
-
- $this->assertTrue($b->updateColumn(3, 'blah', 5));
- $this->assertTrue($b->updateColumn(2, 'boo'));
-
- $column = $b->getColumn(3);
- $this->assertNotEmpty($column);
- $this->assertEquals('blah', $column['title']);
- $this->assertEquals(5, $column['task_limit']);
-
- $column = $b->getColumn(2);
- $this->assertNotEmpty($column);
- $this->assertEquals('boo', $column['title']);
- $this->assertEquals(0, $column['task_limit']);
- }
-
- public function testAddColumn()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $this->assertNotFalse($b->addColumn(1, 'another column'));
- $this->assertNotFalse($b->addColumn(1, 'one more', 3, 'one more description'));
-
- $columns = $b->getColumns(1);
- $this->assertTrue(is_array($columns));
- $this->assertEquals(6, count($columns));
-
- $this->assertEquals('another column', $columns[4]['title']);
- $this->assertEquals(0, $columns[4]['task_limit']);
- $this->assertEquals(5, $columns[4]['position']);
-
- $this->assertEquals('one more', $columns[5]['title']);
- $this->assertEquals(3, $columns[5]['task_limit']);
- $this->assertEquals(6, $columns[5]['position']);
- $this->assertEquals('one more description', $columns[5]['description']);
- }
-
- public function testMoveColumns()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- // We create 2 projects
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
-
- // We get the columns of the project 2
- $columns = $b->getColumns(2);
- $columns_id = array_keys($b->getColumnsList(2));
- $this->assertNotEmpty($columns);
-
- // Initial order: 5, 6, 7, 8
-
- // Move the column 1 down
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals($columns_id[0], $columns[0]['id']);
-
- $this->assertEquals(2, $columns[1]['position']);
- $this->assertEquals($columns_id[1], $columns[1]['id']);
-
- $this->assertTrue($b->moveDown(2, $columns[0]['id']));
- $columns = $b->getColumns(2); // Sorted by position
-
- // New order: 6, 5, 7, 8
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals($columns_id[1], $columns[0]['id']);
-
- $this->assertEquals(2, $columns[1]['position']);
- $this->assertEquals($columns_id[0], $columns[1]['id']);
-
- // Move the column 3 up
- $this->assertTrue($b->moveUp(2, $columns[2]['id']));
- $columns = $b->getColumns(2);
-
- // New order: 6, 7, 5, 8
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals($columns_id[1], $columns[0]['id']);
-
- $this->assertEquals(2, $columns[1]['position']);
- $this->assertEquals($columns_id[2], $columns[1]['id']);
-
- $this->assertEquals(3, $columns[2]['position']);
- $this->assertEquals($columns_id[0], $columns[2]['id']);
-
- // Move column 1 up (must do nothing because it's the first column)
- $this->assertFalse($b->moveUp(2, $columns[0]['id']));
- $columns = $b->getColumns(2);
-
- // Order: 6, 7, 5, 8
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals($columns_id[1], $columns[0]['id']);
-
- // Move column 4 down (must do nothing because it's the last column)
- $this->assertFalse($b->moveDown(2, $columns[3]['id']));
- $columns = $b->getColumns(2);
-
- // Order: 6, 7, 5, 8
-
- $this->assertEquals(4, $columns[3]['position']);
- $this->assertEquals($columns_id[3], $columns[3]['id']);
- }
-
- public function testMoveUpAndRemoveColumn()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- // We create a project
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
-
- // We remove the second column
- $this->assertTrue($b->removeColumn(2));
-
- $columns = $b->getColumns(1);
- $this->assertNotEmpty($columns);
- $this->assertCount(3, $columns);
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals(3, $columns[1]['position']);
- $this->assertEquals(4, $columns[2]['position']);
-
- $this->assertEquals(1, $columns[0]['id']);
- $this->assertEquals(3, $columns[1]['id']);
- $this->assertEquals(4, $columns[2]['id']);
-
- // We move up the second column
- $this->assertTrue($b->moveUp(1, $columns[1]['id']));
-
- // Check the new positions
- $columns = $b->getColumns(1);
- $this->assertNotEmpty($columns);
- $this->assertCount(3, $columns);
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals(2, $columns[1]['position']);
- $this->assertEquals(3, $columns[2]['position']);
-
- $this->assertEquals(3, $columns[0]['id']);
- $this->assertEquals(1, $columns[1]['id']);
- $this->assertEquals(4, $columns[2]['id']);
- }
-
- public function testMoveDownAndRemoveColumn()
- {
- $p = new Project($this->container);
- $b = new Board($this->container);
-
- // We create a project
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
-
- // We remove the second column
- $this->assertTrue($b->removeColumn(2));
-
- $columns = $b->getColumns(1);
- $this->assertNotEmpty($columns);
- $this->assertCount(3, $columns);
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals(3, $columns[1]['position']);
- $this->assertEquals(4, $columns[2]['position']);
-
- $this->assertEquals(1, $columns[0]['id']);
- $this->assertEquals(3, $columns[1]['id']);
- $this->assertEquals(4, $columns[2]['id']);
-
- // We move up the second column
- $this->assertTrue($b->moveDown(1, $columns[0]['id']));
-
- // Check the new positions
- $columns = $b->getColumns(1);
- $this->assertNotEmpty($columns);
- $this->assertCount(3, $columns);
-
- $this->assertEquals(1, $columns[0]['position']);
- $this->assertEquals(2, $columns[1]['position']);
- $this->assertEquals(3, $columns[2]['position']);
-
- $this->assertEquals(3, $columns[0]['id']);
- $this->assertEquals(1, $columns[1]['id']);
- $this->assertEquals(4, $columns[2]['id']);
- }
}
diff --git a/tests/units/Model/ColumnTest.php b/tests/units/Model/ColumnTest.php
new file mode 100644
index 00000000..e40f89c6
--- /dev/null
+++ b/tests/units/Model/ColumnTest.php
@@ -0,0 +1,236 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Project;
+use Kanboard\Model\Column;
+
+class ColumnTest extends Base
+{
+ public function testGetColumn()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $column = $columnModel->getById(3);
+ $this->assertNotEmpty($column);
+ $this->assertEquals('Work in progress', $column['title']);
+
+ $column = $columnModel->getById(33);
+ $this->assertEmpty($column);
+ }
+
+ public function testGetFirstColumnId()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertEquals(1, $columnModel->getFirstColumnId(1));
+ }
+
+ public function testGetLastColumnId()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertEquals(4, $columnModel->getLastColumnId(1));
+ }
+
+ public function testGetLastColumnPosition()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertEquals(4, $columnModel->getLastColumnPosition(1));
+ }
+
+ public function testGetColumnIdByTitle()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertEquals(2, $columnModel->getColumnIdByTitle(1, 'Ready'));
+ }
+
+ public function testGetTitleByColumnId()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertEquals('Work in progress', $columnModel->getColumnTitleById(3));
+ }
+
+ public function testGetAll()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertCount(4, $columns);
+
+ $this->assertEquals(1, $columns[0]['id']);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals('Backlog', $columns[0]['title']);
+
+ $this->assertEquals(2, $columns[1]['id']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals('Ready', $columns[1]['title']);
+
+ $this->assertEquals(3, $columns[2]['id']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals('Work in progress', $columns[2]['title']);
+
+ $this->assertEquals(4, $columns[3]['id']);
+ $this->assertEquals(4, $columns[3]['position']);
+ $this->assertEquals('Done', $columns[3]['title']);
+ }
+
+ public function testGetList()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $columns = $columnModel->getList(1);
+ $this->assertCount(4, $columns);
+ $this->assertEquals('Backlog', $columns[1]);
+ $this->assertEquals('Ready', $columns[2]);
+ $this->assertEquals('Work in progress', $columns[3]);
+ $this->assertEquals('Done', $columns[4]);
+
+ $columns = $columnModel->getList(1, true);
+ $this->assertCount(5, $columns);
+ $this->assertEquals('All columns', $columns[-1]);
+ $this->assertEquals('Backlog', $columns[1]);
+ $this->assertEquals('Ready', $columns[2]);
+ $this->assertEquals('Work in progress', $columns[3]);
+ $this->assertEquals('Done', $columns[4]);
+ }
+
+ public function testAddColumn()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertNotFalse($columnModel->create(1, 'another column'));
+ $this->assertNotFalse($columnModel->create(1, 'one more', 3, 'one more description'));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertTrue(is_array($columns));
+ $this->assertEquals(6, count($columns));
+
+ $this->assertEquals('another column', $columns[4]['title']);
+ $this->assertEquals(0, $columns[4]['task_limit']);
+ $this->assertEquals(5, $columns[4]['position']);
+
+ $this->assertEquals('one more', $columns[5]['title']);
+ $this->assertEquals(3, $columns[5]['task_limit']);
+ $this->assertEquals(6, $columns[5]['position']);
+ $this->assertEquals('one more description', $columns[5]['description']);
+ }
+
+ public function testUpdateColumn()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $this->assertTrue($columnModel->update(3, 'blah', 5));
+ $this->assertTrue($columnModel->update(2, 'boo'));
+
+ $column = $columnModel->getById(3);
+ $this->assertNotEmpty($column);
+ $this->assertEquals('blah', $column['title']);
+ $this->assertEquals(5, $column['task_limit']);
+
+ $column = $columnModel->getById(2);
+ $this->assertNotEmpty($column);
+ $this->assertEquals('boo', $column['title']);
+ $this->assertEquals(0, $column['task_limit']);
+ }
+
+ public function testRemoveColumn()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertTrue($columnModel->remove(3));
+ $this->assertFalse($columnModel->remove(322));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertTrue(is_array($columns));
+ $this->assertEquals(3, count($columns));
+ }
+
+ public function testChangePosition()
+ {
+ $projectModel = new Project($this->container);
+ $columnModel = new Column($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals(1, $columns[0]['id']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals(2, $columns[1]['id']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals(3, $columns[2]['id']);
+
+ $this->assertTrue($columnModel->changePosition(1, 3, 2));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals(1, $columns[0]['id']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals(3, $columns[1]['id']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals(2, $columns[2]['id']);
+
+ $this->assertTrue($columnModel->changePosition(1, 2, 1));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals(2, $columns[0]['id']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals(1, $columns[1]['id']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals(3, $columns[2]['id']);
+
+ $this->assertTrue($columnModel->changePosition(1, 2, 2));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals(1, $columns[0]['id']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals(2, $columns[1]['id']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals(3, $columns[2]['id']);
+
+ $this->assertTrue($columnModel->changePosition(1, 4, 1));
+
+ $columns = $columnModel->getAll(1);
+ $this->assertEquals(1, $columns[0]['position']);
+ $this->assertEquals(4, $columns[0]['id']);
+ $this->assertEquals(2, $columns[1]['position']);
+ $this->assertEquals(1, $columns[1]['id']);
+ $this->assertEquals(3, $columns[2]['position']);
+ $this->assertEquals(2, $columns[2]['id']);
+
+ $this->assertFalse($columnModel->changePosition(1, 2, 0));
+ $this->assertFalse($columnModel->changePosition(1, 2, 5));
+ }
+}
diff --git a/tests/units/Model/CommentTest.php b/tests/units/Model/CommentTest.php
index 07c39fe3..ec4e7a56 100644
--- a/tests/units/Model/CommentTest.php
+++ b/tests/units/Model/CommentTest.php
@@ -90,53 +90,4 @@ class CommentTest extends Base
$this->assertFalse($c->remove(1));
$this->assertFalse($c->remove(1111));
}
-
- public function testValidateCreation()
- {
- $c = new Comment($this->container);
-
- $result = $c->validateCreation(array('user_id' => 1, 'task_id' => 1, 'comment' => 'bla'));
- $this->assertTrue($result[0]);
-
- $result = $c->validateCreation(array('user_id' => 1, 'task_id' => 1, 'comment' => ''));
- $this->assertFalse($result[0]);
-
- $result = $c->validateCreation(array('user_id' => 1, 'task_id' => 'a', 'comment' => 'bla'));
- $this->assertFalse($result[0]);
-
- $result = $c->validateCreation(array('user_id' => 'b', 'task_id' => 1, 'comment' => 'bla'));
- $this->assertFalse($result[0]);
-
- $result = $c->validateCreation(array('user_id' => 1, 'comment' => 'bla'));
- $this->assertFalse($result[0]);
-
- $result = $c->validateCreation(array('task_id' => 1, 'comment' => 'bla'));
- $this->assertTrue($result[0]);
-
- $result = $c->validateCreation(array('comment' => 'bla'));
- $this->assertFalse($result[0]);
-
- $result = $c->validateCreation(array());
- $this->assertFalse($result[0]);
- }
-
- public function testValidateModification()
- {
- $c = new Comment($this->container);
-
- $result = $c->validateModification(array('id' => 1, 'comment' => 'bla'));
- $this->assertTrue($result[0]);
-
- $result = $c->validateModification(array('id' => 1, 'comment' => ''));
- $this->assertFalse($result[0]);
-
- $result = $c->validateModification(array('comment' => 'bla'));
- $this->assertFalse($result[0]);
-
- $result = $c->validateModification(array('id' => 'b', 'comment' => 'bla'));
- $this->assertFalse($result[0]);
-
- $result = $c->validateModification(array());
- $this->assertFalse($result[0]);
- }
}
diff --git a/tests/units/Model/ConfigTest.php b/tests/units/Model/ConfigTest.php
index 17617ceb..447c9238 100644
--- a/tests/units/Model/ConfigTest.php
+++ b/tests/units/Model/ConfigTest.php
@@ -3,10 +3,78 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Model\Config;
-use Kanboard\Core\Session;
+use Kanboard\Core\Session\SessionManager;
class ConfigTest extends Base
{
+ public function testGetTimezones()
+ {
+ $configModel = new Config($this->container);
+ $this->assertNotEmpty($configModel->getTimezones());
+ $this->assertArrayHasKey('Europe/Paris', $configModel->getTimezones());
+ $this->assertContains('Europe/Paris', $configModel->getTimezones());
+ $this->assertArrayNotHasKey('', $configModel->getTimezones());
+
+ $this->assertArrayHasKey('', $configModel->getTimezones(true));
+ $this->assertContains('Application default', $configModel->getTimezones(true));
+ }
+
+ public function testGetLanguages()
+ {
+ $configModel = new Config($this->container);
+ $this->assertNotEmpty($configModel->getLanguages());
+ $this->assertArrayHasKey('fr_FR', $configModel->getLanguages());
+ $this->assertContains('Français', $configModel->getLanguages());
+ $this->assertArrayNotHasKey('', $configModel->getLanguages());
+
+ $this->assertArrayHasKey('', $configModel->getLanguages(true));
+ $this->assertContains('Application default', $configModel->getLanguages(true));
+ }
+
+ public function testGetJsLanguage()
+ {
+ $configModel = new Config($this->container);
+ $this->assertEquals('en', $configModel->getJsLanguageCode());
+
+ $this->container['sessionStorage']->user = array('language' => 'fr_FR');
+ $this->assertEquals('fr', $configModel->getJsLanguageCode());
+
+ $this->container['sessionStorage']->user = array('language' => 'xx_XX');
+ $this->assertEquals('en', $configModel->getJsLanguageCode());
+ }
+
+ public function testGetCurrentLanguage()
+ {
+ $configModel = new Config($this->container);
+ $this->assertEquals('en_US', $configModel->getCurrentLanguage());
+
+ $this->container['sessionStorage']->user = array('language' => 'fr_FR');
+ $this->assertEquals('fr_FR', $configModel->getCurrentLanguage());
+
+ $this->container['sessionStorage']->user = array('language' => 'xx_XX');
+ $this->assertEquals('xx_XX', $configModel->getCurrentLanguage());
+ }
+
+ public function testGetCurrentTimezone()
+ {
+ $configModel = new Config($this->container);
+ $this->assertEquals('UTC', $configModel->getCurrentTimezone());
+
+ $this->container['sessionStorage']->user = array('timezone' => 'Europe/Paris');
+ $this->assertEquals('Europe/Paris', $configModel->getCurrentTimezone());
+
+ $this->container['sessionStorage']->user = array('timezone' => 'Something');
+ $this->assertEquals('Something', $configModel->getCurrentTimezone());
+ }
+
+ public function testRegenerateToken()
+ {
+ $configModel = new Config($this->container);
+ $token = $configModel->getOption('api_token');
+ $this->assertTrue($configModel->regenerateToken('api_token'));
+ $this->assertNotEquals($token, $configModel->getOption('api_token'));
+ }
+
public function testCRUDOperations()
{
$c = new Config($this->container);
@@ -37,62 +105,73 @@ class ConfigTest extends Base
$c = new Config($this->container);
$this->assertTrue($c->save(array('application_url' => 'http://localhost/')));
- $this->assertEquals('http://localhost/', $c->get('application_url'));
+ $this->assertEquals('http://localhost/', $c->getOption('application_url'));
$this->assertTrue($c->save(array('application_url' => 'http://localhost')));
- $this->assertEquals('http://localhost/', $c->get('application_url'));
+ $this->assertEquals('http://localhost/', $c->getOption('application_url'));
$this->assertTrue($c->save(array('application_url' => '')));
- $this->assertEquals('', $c->get('application_url'));
+ $this->assertEquals('', $c->getOption('application_url'));
}
public function testDefaultValues()
{
$c = new Config($this->container);
- $this->assertEquals('en_US', $c->get('application_language'));
- $this->assertEquals('UTC', $c->get('application_timezone'));
-
- $this->assertEmpty($c->get('webhook_url_task_modification'));
- $this->assertEmpty($c->get('webhook_url_task_creation'));
- $this->assertEmpty($c->get('board_columns'));
- $this->assertEmpty($c->get('application_url'));
-
- $this->assertNotEmpty($c->get('webhook_token'));
- $this->assertNotEmpty($c->get('api_token'));
+ $this->assertEquals(172800, $c->getOption('board_highlight_period'));
+ $this->assertEquals(60, $c->getOption('board_public_refresh_interval'));
+ $this->assertEquals(10, $c->getOption('board_private_refresh_interval'));
+ $this->assertEmpty($c->getOption('board_columns'));
+
+ $this->assertEquals('yellow', $c->getOption('default_color'));
+ $this->assertEquals('en_US', $c->getOption('application_language'));
+ $this->assertEquals('UTC', $c->getOption('application_timezone'));
+ $this->assertEquals('m/d/Y', $c->getOption('application_date_format'));
+ $this->assertEmpty($c->getOption('application_url'));
+ $this->assertEmpty($c->getOption('application_stylesheet'));
+ $this->assertEquals('USD', $c->getOption('application_currency'));
+
+ $this->assertEquals(0, $c->getOption('calendar_user_subtasks_time_tracking'));
+ $this->assertEquals('date_started', $c->getOption('calendar_user_tasks'));
+ $this->assertEquals('date_started', $c->getOption('calendar_user_tasks'));
+
+ $this->assertEquals(0, $c->getOption('integration_gravatar'));
+ $this->assertEquals(1, $c->getOption('cfd_include_closed_tasks'));
+ $this->assertEquals(1, $c->getOption('password_reset'));
+
+ $this->assertEquals(1, $c->getOption('subtask_time_tracking'));
+ $this->assertEquals(0, $c->getOption('subtask_restriction'));
+ $this->assertEmpty($c->getOption('project_categories'));
+
+ $this->assertEmpty($c->getOption('webhook_url_task_modification'));
+ $this->assertEmpty($c->getOption('webhook_url_task_creation'));
+ $this->assertNotEmpty($c->getOption('webhook_token'));
+ $this->assertEmpty($c->getOption('webhook_url'));
+
+ $this->assertNotEmpty($c->getOption('api_token'));
}
- public function testGet()
+ public function testGetOption()
{
$c = new Config($this->container);
- $this->assertEquals('', $c->get('board_columns'));
- $this->assertEquals('test', $c->get('board_columns', 'test'));
- $this->assertEquals(0, $c->get('board_columns', 0));
+ $this->assertEquals('', $c->getOption('board_columns'));
+ $this->assertEquals('test', $c->getOption('board_columns', 'test'));
+ $this->assertEquals(0, $c->getOption('board_columns', 0));
}
- public function testGetWithSession()
+ public function testGetWithCaching()
{
- $this->container['session'] = new Session;
$c = new Config($this->container);
- session_id('test');
+ $this->assertEquals('UTC', $c->get('application_timezone'));
+ $this->assertTrue($c->save(array('application_timezone' => 'Europe/Paris')));
- $this->assertTrue(Session::isOpen());
+ $this->assertEquals('UTC', $c->get('application_timezone')); // cached value
+ $this->assertEquals('Europe/Paris', $c->getOption('application_timezone'));
$this->assertEquals('', $c->get('board_columns'));
$this->assertEquals('test', $c->get('board_columns', 'test'));
-
- $this->container['session']['config'] = array(
- 'board_columns' => 'foo',
- 'empty_value' => 0
- );
-
- $this->assertEquals('foo', $c->get('board_columns'));
- $this->assertEquals('foo', $c->get('board_columns', 'test'));
$this->assertEquals('test', $c->get('empty_value', 'test'));
-
- session_id('');
- unset($this->container['session']);
}
}
diff --git a/tests/units/Model/CurrencyTest.php b/tests/units/Model/CurrencyTest.php
new file mode 100644
index 00000000..0bc71da6
--- /dev/null
+++ b/tests/units/Model/CurrencyTest.php
@@ -0,0 +1,53 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Currency;
+
+class CurrencyTest extends Base
+{
+ public function testGetCurrencies()
+ {
+ $currencyModel = new Currency($this->container);
+ $currencies = $currencyModel->getCurrencies();
+ $this->assertArrayHasKey('EUR', $currencies);
+ }
+
+ public function testGetAll()
+ {
+ $currencyModel = new Currency($this->container);
+ $currencies = $currencyModel->getAll();
+ $this->assertCount(0, $currencies);
+
+ $this->assertNotFalse($currencyModel->create('USD', 9.9));
+ $currencies = $currencyModel->getAll();
+ $this->assertCount(1, $currencies);
+ $this->assertEquals('USD', $currencies[0]['currency']);
+ $this->assertEquals(9.9, $currencies[0]['rate']);
+ }
+
+ public function testCreate()
+ {
+ $currencyModel = new Currency($this->container);
+ $this->assertNotFalse($currencyModel->create('EUR', 1.2));
+ $this->assertNotFalse($currencyModel->create('EUR', 1.5));
+ }
+
+ public function testUpdate()
+ {
+ $currencyModel = new Currency($this->container);
+ $this->assertNotFalse($currencyModel->create('EUR', 1.1));
+ $this->assertNotFalse($currencyModel->update('EUR', 2.2));
+ }
+
+ public function testGetPrice()
+ {
+ $currencyModel = new Currency($this->container);
+
+ $this->assertEquals(123, $currencyModel->getPrice('USD', 123));
+
+ $this->assertNotFalse($currencyModel->create('EUR', 0.5));
+ $this->assertEquals(50.0, $currencyModel->getPrice('EUR', 100));
+ $this->assertEquals(50.0, $currencyModel->getPrice('EUR', 100)); // test with cached result
+ }
+}
diff --git a/tests/units/Model/CustomFilterTest.php b/tests/units/Model/CustomFilterTest.php
index 190da899..a73bc401 100644
--- a/tests/units/Model/CustomFilterTest.php
+++ b/tests/units/Model/CustomFilterTest.php
@@ -52,34 +52,6 @@ class CustomFilterTest extends Base
$this->assertEquals(1, $filter['is_shared']);
}
- public function testValidation()
- {
- $cf = new CustomFilter($this->container);
-
- // Validate creation
- $r = $cf->validateCreation(array('filter' => 'test', 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertTrue($r[0]);
-
- $r = $cf->validateCreation(array('filter' => str_repeat('a', 101), 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertFalse($r[0]);
-
- $r = $cf->validateCreation(array('name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertFalse($r[0]);
-
- // Validate modification
- $r = $cf->validateModification(array('id' => 1, 'filter' => 'test', 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertTrue($r[0]);
-
- $r = $cf->validateModification(array('filter' => 'test', 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertFalse($r[0]);
-
- $r = $cf->validateModification(array('id' => 1, 'filter' => str_repeat('a', 101), 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertFalse($r[0]);
-
- $r = $cf->validateModification(array('id' => 1, 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
- $this->assertFalse($r[0]);
- }
-
public function testGetAll()
{
$u = new User($this->container);
diff --git a/tests/units/Model/FileTest.php b/tests/units/Model/FileTest.php
deleted file mode 100644
index 29f6ee93..00000000
--- a/tests/units/Model/FileTest.php
+++ /dev/null
@@ -1,263 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Model\Task;
-use Kanboard\Model\File;
-use Kanboard\Model\TaskCreation;
-use Kanboard\Model\Project;
-
-class FileTest extends Base
-{
- public function setUp()
- {
- parent::setUp();
-
- $this->container['objectStorage'] = $this
- ->getMockBuilder('\Kanboard\Core\ObjectStorage\FileStorage')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('put', 'moveFile', 'remove'))
- ->getMock();
- }
-
- 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);
- $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->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);
- $this->assertCount(2, $files);
-
- $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'.DIRECTORY_SEPARATOR.'34'.DIRECTORY_SEPARATOR, $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);
- $tc = new TaskCreation($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test')));
-
- $data = base64_encode('image data');
-
- $f = $this
- ->getMockBuilder('\Kanboard\Model\File')
- ->setConstructorArgs(array($this->container))
- ->setMethods(array('generateThumbnailFromData'))
- ->getMock();
-
- $this->container['objectStorage']
- ->expects($this->once())
- ->method('put')
- ->with(
- $this->stringContains('1'.DIRECTORY_SEPARATOR.'1'.DIRECTORY_SEPARATOR),
- $this->equalTo(base64_decode($data))
- )
- ->will($this->returnValue(true));
-
- $f->expects($this->once())
- ->method('generateThumbnailFromData');
-
- $this->assertEquals(1, $f->uploadScreenshot(1, 1, $data));
-
- $file = $f->getById(1);
- $this->assertNotEmpty($file);
- $this->assertStringStartsWith('Screenshot taken ', $file['name']);
- $this->assertStringStartsWith('1'.DIRECTORY_SEPARATOR.'1'.DIRECTORY_SEPARATOR, $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')));
-
- $data = base64_encode('file data');
-
- $this->container['objectStorage']
- ->expects($this->once())
- ->method('put')
- ->with(
- $this->stringContains('1'.DIRECTORY_SEPARATOR.'1'.DIRECTORY_SEPARATOR),
- $this->equalTo(base64_decode($data))
- )
- ->will($this->returnValue(true));
-
- $this->assertEquals(1, $f->uploadContent(1, 1, 'my file.pdf', $data));
-
- $file = $f->getById(1);
- $this->assertNotEmpty($file);
- $this->assertEquals('my file.pdf', $file['name']);
- $this->assertStringStartsWith('1'.DIRECTORY_SEPARATOR.'1'.DIRECTORY_SEPARATOR, $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', DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo1', 10));
- $this->assertEquals(2, $f->create(1, 'A.png', DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo2', 10));
- $this->assertEquals(3, $f->create(1, 'D.doc', DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo3', 10));
-
- $this->container['objectStorage']
- ->expects($this->at(0))
- ->method('remove')
- ->with(
- $this->equalTo(DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo2')
- )
- ->will($this->returnValue(true));
-
- $this->container['objectStorage']
- ->expects($this->at(1))
- ->method('remove')
- ->with(
- $this->equalTo('thumbnails'.DIRECTORY_SEPARATOR.DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo2')
- )
- ->will($this->returnValue(true));
-
- $this->container['objectStorage']
- ->expects($this->at(2))
- ->method('remove')
- ->with(
- $this->equalTo(DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo1')
- )
- ->will($this->returnValue(true));
-
- $this->container['objectStorage']
- ->expects($this->at(3))
- ->method('remove')
- ->with(
- $this->equalTo(DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'foo3')
- )
- ->will($this->returnValue(true));
-
- $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/Model/GroupMemberTest.php b/tests/units/Model/GroupMemberTest.php
new file mode 100644
index 00000000..16f769e8
--- /dev/null
+++ b/tests/units/Model/GroupMemberTest.php
@@ -0,0 +1,76 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Group;
+use Kanboard\Model\User;
+use Kanboard\Model\GroupMember;
+
+class GroupMemberTest extends Base
+{
+ public function testAddRemove()
+ {
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+
+ $this->assertEquals(1, $groupModel->create('Test'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 1));
+ $this->assertFalse($groupMemberModel->addUser(1, 1));
+
+ $users = $groupMemberModel->getMembers(1);
+ $this->assertCount(1, $users);
+ $this->assertEquals('admin', $users[0]['username']);
+
+ $this->assertEmpty($groupMemberModel->getNotMembers(1));
+
+ $this->assertTrue($groupMemberModel->removeUser(1, 1));
+ $this->assertFalse($groupMemberModel->removeUser(1, 1));
+
+ $this->assertEmpty($groupMemberModel->getMembers(1));
+ }
+
+ public function testMembers()
+ {
+ $userModel = new User($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user4')));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 1));
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
+ $this->assertTrue($groupMemberModel->addUser(1, 5));
+ $this->assertTrue($groupMemberModel->addUser(2, 3));
+ $this->assertTrue($groupMemberModel->addUser(2, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+
+ $users = $groupMemberModel->getMembers(1);
+ $this->assertCount(3, $users);
+ $this->assertEquals('admin', $users[0]['username']);
+ $this->assertEquals('user1', $users[1]['username']);
+ $this->assertEquals('user4', $users[2]['username']);
+
+ $users = $groupMemberModel->getNotMembers(1);
+ $this->assertCount(2, $users);
+ $this->assertEquals('user2', $users[0]['username']);
+ $this->assertEquals('user3', $users[1]['username']);
+
+ $users = $groupMemberModel->getMembers(2);
+ $this->assertCount(3, $users);
+ $this->assertEquals('user2', $users[0]['username']);
+ $this->assertEquals('user3', $users[1]['username']);
+ $this->assertEquals('user4', $users[2]['username']);
+
+ $users = $groupMemberModel->getNotMembers(2);
+ $this->assertCount(2, $users);
+ $this->assertEquals('admin', $users[0]['username']);
+ $this->assertEquals('user1', $users[1]['username']);
+ }
+}
diff --git a/tests/units/Model/GroupTest.php b/tests/units/Model/GroupTest.php
new file mode 100644
index 00000000..36b47dc5
--- /dev/null
+++ b/tests/units/Model/GroupTest.php
@@ -0,0 +1,60 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Group;
+
+class GroupTest extends Base
+{
+ public function testCreation()
+ {
+ $groupModel = new Group($this->container);
+ $this->assertEquals(1, $groupModel->create('Test'));
+ $this->assertFalse($groupModel->create('Test'));
+ }
+
+ public function testGetById()
+ {
+ $groupModel = new Group($this->container);
+ $this->assertEquals(1, $groupModel->create('Test'));
+
+ $group = $groupModel->getById(1);
+ $this->assertEquals('Test', $group['name']);
+ $this->assertEquals('', $group['external_id']);
+
+ $this->assertEmpty($groupModel->getById(2));
+ }
+
+ public function testGetAll()
+ {
+ $groupModel = new Group($this->container);
+ $this->assertEquals(1, $groupModel->create('B'));
+ $this->assertEquals(2, $groupModel->create('A', 'uuid'));
+
+ $groups = $groupModel->getAll();
+ $this->assertCount(2, $groups);
+ $this->assertEquals('A', $groups[0]['name']);
+ $this->assertEquals('uuid', $groups[0]['external_id']);
+ $this->assertEquals('B', $groups[1]['name']);
+ $this->assertEquals('', $groups[1]['external_id']);
+ }
+
+ public function testUpdate()
+ {
+ $groupModel = new Group($this->container);
+ $this->assertEquals(1, $groupModel->create('Test'));
+ $this->assertTrue($groupModel->update(array('id' => 1, 'name' => 'My group', 'external_id' => 'test')));
+
+ $group = $groupModel->getById(1);
+ $this->assertEquals('My group', $group['name']);
+ $this->assertEquals('test', $group['external_id']);
+ }
+
+ public function testRemove()
+ {
+ $groupModel = new Group($this->container);
+ $this->assertEquals(1, $groupModel->create('Test'));
+ $this->assertTrue($groupModel->remove(1));
+ $this->assertEmpty($groupModel->getById(1));
+ }
+}
diff --git a/tests/units/Model/LastLoginTest.php b/tests/units/Model/LastLoginTest.php
new file mode 100644
index 00000000..fc5ea1e5
--- /dev/null
+++ b/tests/units/Model/LastLoginTest.php
@@ -0,0 +1,43 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\LastLogin;
+
+class LastLoginTest extends Base
+{
+ public function testCreate()
+ {
+ $lastLoginModel = new LastLogin($this->container);
+
+ $this->assertTrue($lastLoginModel->create('Test1', 1, '127.0.0.1', 'My browser'));
+ $this->assertTrue($lastLoginModel->create('Test2', 1, '127.0.0.1', str_repeat('Too long', 50)));
+ $this->assertTrue($lastLoginModel->create('Test3', 1, '2001:0db8:0000:0000:0000:ff00:0042:8329', 'My Ipv6 browser'));
+
+ $connections = $lastLoginModel->getAll(1);
+ $this->assertCount(3, $connections);
+
+ $this->assertEquals('Test3', $connections[0]['auth_type']);
+ $this->assertEquals('2001:0db8:0000:0000:0000:ff00:0042:8329', $connections[0]['ip']);
+
+ $this->assertEquals('Test2', $connections[1]['auth_type']);
+ $this->assertEquals('127.0.0.1', $connections[1]['ip']);
+
+ $this->assertEquals('Test1', $connections[2]['auth_type']);
+ $this->assertEquals('127.0.0.1', $connections[2]['ip']);
+ }
+
+ public function testCleanup()
+ {
+ $lastLoginModel = new LastLogin($this->container);
+
+ for ($i = 0; $i < $lastLoginModel::NB_LOGINS + 5; $i++) {
+ $this->assertTrue($lastLoginModel->create('Test' . $i, 1, '127.0.0.1', 'My browser'));
+ }
+
+ $connections = $lastLoginModel->getAll(1);
+ $this->assertCount(10, $connections);
+ $this->assertEquals('Test14', $connections[0]['auth_type']);
+ $this->assertEquals('Test5', $connections[9]['auth_type']);
+ }
+}
diff --git a/tests/units/Model/LinkTest.php b/tests/units/Model/LinkTest.php
index de9d843a..b102646d 100644
--- a/tests/units/Model/LinkTest.php
+++ b/tests/units/Model/LinkTest.php
@@ -124,50 +124,4 @@ class LinkTest extends Base
$this->assertArrayNotHasKey(0, $links);
$this->assertEquals('relates to', $links[1]);
}
-
- public function testValidateCreation()
- {
- $l = new Link($this->container);
-
- $r = $l->validateCreation(array('label' => 'a'));
- $this->assertTrue($r[0]);
-
- $r = $l->validateCreation(array('label' => 'a', 'opposite_label' => 'b'));
- $this->assertTrue($r[0]);
-
- $r = $l->validateCreation(array('label' => 'relates to'));
- $this->assertFalse($r[0]);
-
- $r = $l->validateCreation(array('label' => 'a', 'opposite_label' => 'a'));
- $this->assertFalse($r[0]);
-
- $r = $l->validateCreation(array('label' => ''));
- $this->assertFalse($r[0]);
- }
-
- public function testValidateModification()
- {
- $l = new Link($this->container);
-
- $r = $l->validateModification(array('id' => 20, 'label' => 'a', 'opposite_id' => 0));
- $this->assertTrue($r[0]);
-
- $r = $l->validateModification(array('id' => 20, 'label' => 'a', 'opposite_id' => '1'));
- $this->assertTrue($r[0]);
-
- $r = $l->validateModification(array('id' => 20, 'label' => 'relates to', 'opposite_id' => '1'));
- $this->assertFalse($r[0]);
-
- $r = $l->validateModification(array('id' => 20, 'label' => '', 'opposite_id' => '1'));
- $this->assertFalse($r[0]);
-
- $r = $l->validateModification(array('label' => '', 'opposite_id' => '1'));
- $this->assertFalse($r[0]);
-
- $r = $l->validateModification(array('id' => 20, 'opposite_id' => '1'));
- $this->assertFalse($r[0]);
-
- $r = $l->validateModification(array('label' => 'test'));
- $this->assertFalse($r[0]);
- }
}
diff --git a/tests/units/Model/NotificationTest.php b/tests/units/Model/NotificationTest.php
index 7f9977ce..03ee5867 100644
--- a/tests/units/Model/NotificationTest.php
+++ b/tests/units/Model/NotificationTest.php
@@ -7,7 +7,7 @@ use Kanboard\Model\TaskCreation;
use Kanboard\Model\Subtask;
use Kanboard\Model\Comment;
use Kanboard\Model\User;
-use Kanboard\Model\File;
+use Kanboard\Model\TaskFile;
use Kanboard\Model\Task;
use Kanboard\Model\Project;
use Kanboard\Model\Notification;
@@ -23,7 +23,7 @@ class NotificationTest extends Base
$tc = new TaskCreation($this->container);
$s = new Subtask($this->container);
$c = new Comment($this->container);
- $f = new File($this->container);
+ $f = new TaskFile($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1)));
diff --git a/tests/units/Model/PasswordResetTest.php b/tests/units/Model/PasswordResetTest.php
new file mode 100644
index 00000000..f88d24fb
--- /dev/null
+++ b/tests/units/Model/PasswordResetTest.php
@@ -0,0 +1,85 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\User;
+use Kanboard\Model\PasswordReset;
+
+class PasswordResetTest extends Base
+{
+ public function testCreate()
+ {
+ $userModel = new User($this->container);
+ $passwordResetModel = new PasswordReset($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'email' => 'user1@localhost')));
+
+ $this->assertFalse($passwordResetModel->create('user0'));
+ $this->assertFalse($passwordResetModel->create('user1'));
+ $this->assertNotFalse($passwordResetModel->create('user2'));
+ }
+
+ public function testGetUserIdByToken()
+ {
+ $userModel = new User($this->container);
+ $passwordResetModel = new PasswordReset($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user2', 'email' => 'user1@localhost')));
+
+ $token = $passwordResetModel->create('user2');
+ $this->assertEquals(2, $passwordResetModel->getUserIdByToken($token));
+ }
+
+ public function testGetUserIdByTokenWhenExpired()
+ {
+ $userModel = new User($this->container);
+ $passwordResetModel = new PasswordReset($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user2', 'email' => 'user1@localhost')));
+
+ $token = $passwordResetModel->create('user2', strtotime('-1 year'));
+ $this->assertFalse($passwordResetModel->getUserIdByToken($token));
+ }
+
+ public function testDisableTokens()
+ {
+ $userModel = new User($this->container);
+ $passwordResetModel = new PasswordReset($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user2', 'email' => 'user1@localhost')));
+
+ $token1 = $passwordResetModel->create('user2');
+ $token2 = $passwordResetModel->create('user2');
+
+ $this->assertEquals(2, $passwordResetModel->getUserIdByToken($token1));
+ $this->assertEquals(2, $passwordResetModel->getUserIdByToken($token2));
+
+ $this->assertTrue($passwordResetModel->disable(2));
+
+ $this->assertFalse($passwordResetModel->getUserIdByToken($token1));
+ $this->assertFalse($passwordResetModel->getUserIdByToken($token2));
+ }
+
+ public function testGetAll()
+ {
+ $userModel = new User($this->container);
+ $passwordResetModel = new PasswordReset($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user2', 'email' => 'user1@localhost')));
+ $this->assertNotFalse($passwordResetModel->create('user2'));
+ $this->assertNotFalse($passwordResetModel->create('user2'));
+
+ $tokens = $passwordResetModel->getAll(1);
+ $this->assertCount(0, $tokens);
+
+ $tokens = $passwordResetModel->getAll(2);
+ $this->assertCount(2, $tokens);
+ $this->assertNotEmpty($tokens[0]['token']);
+ $this->assertNotEmpty($tokens[0]['date_creation']);
+ $this->assertNotEmpty($tokens[0]['date_expiration']);
+ $this->assertEquals(2, $tokens[0]['user_id']);
+ $this->assertArrayHasKey('user_agent', $tokens[0]);
+ $this->assertArrayHasKey('ip', $tokens[0]);
+ }
+}
diff --git a/tests/units/Model/ProjectActivityTest.php b/tests/units/Model/ProjectActivityTest.php
index 5a242cb2..04d3004d 100644
--- a/tests/units/Model/ProjectActivityTest.php
+++ b/tests/units/Model/ProjectActivityTest.php
@@ -9,7 +9,6 @@ use Kanboard\Model\ProjectActivity;
use Kanboard\Model\Project;
use Kanboard\Model\Subtask;
use Kanboard\Model\Comment;
-use Kanboard\Model\File;
class ProjectActivityTest extends Base
{
@@ -87,9 +86,10 @@ class ProjectActivityTest extends Base
$max = 15;
$nb_events = 100;
+ $task = $tf->getbyId(1);
for ($i = 0; $i < $nb_events; $i++) {
- $this->assertTrue($e->createEvent(1, 1, 1, Task::EVENT_CLOSE, array('task' => $tf->getbyId(1))));
+ $this->assertTrue($e->createEvent(1, 1, 1, Task::EVENT_CLOSE, array('task' => $task)));
}
$this->assertEquals($nb_events, $this->container['db']->table('project_activities')->count());
@@ -98,20 +98,9 @@ class ProjectActivityTest extends Base
$events = $e->getProject(1);
$this->assertNotEmpty($events);
- $this->assertTrue(is_array($events));
- $this->assertEquals($max, count($events));
+ $this->assertCount($max, $events);
$this->assertEquals(100, $events[0]['id']);
$this->assertEquals(99, $events[1]['id']);
$this->assertEquals(86, $events[14]['id']);
-
- // Cleanup during task creation
-
- $nb_events = ProjectActivity::MAX_EVENTS + 10;
-
- for ($i = 0; $i < $nb_events; $i++) {
- $this->assertTrue($e->createEvent(1, 1, 1, Task::EVENT_CLOSE, array('task' => $tf->getbyId(1))));
- }
-
- $this->assertEquals(ProjectActivity::MAX_EVENTS, $this->container['db']->table('project_activities')->count());
}
}
diff --git a/tests/units/Model/ProjectDailyColumnStatsTest.php b/tests/units/Model/ProjectDailyColumnStatsTest.php
index 4c801e02..5e8ec3e8 100644
--- a/tests/units/Model/ProjectDailyColumnStatsTest.php
+++ b/tests/units/Model/ProjectDailyColumnStatsTest.php
@@ -4,87 +4,262 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Model\Project;
use Kanboard\Model\ProjectDailyColumnStats;
+use Kanboard\Model\Config;
use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskStatus;
class ProjectDailyColumnStatsTest extends Base
{
+ public function testUpdateTotalsWithScoreAtNull()
+ {
+ $projectModel = new Project($this->container);
+ $projectDailyColumnStats = new ProjectDailyColumnStats($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $task = $this->container['db']->table(Task::TABLE)->findOne();
+ $this->assertNull($task['score']);
+
+ $stats = $this->container['db']->table(ProjectDailyColumnStats::TABLE)
+ ->asc('day')
+ ->asc('column_id')
+ ->columns('day', 'project_id', 'column_id', 'total', 'score')
+ ->findAll();
+
+ $expected = array(
+ array(
+ 'day' => '2016-01-16',
+ 'project_id' => 1,
+ 'column_id' => 1,
+ 'total' => 1,
+ 'score' => 0,
+ ),
+ );
+
+ $this->assertEquals($expected, $stats);
+ }
+
public function testUpdateTotals()
{
- $p = new Project($this->container);
- $pds = new ProjectDailyColumnStats($this->container);
- $tc = new TaskCreation($this->container);
- $ts = new TaskStatus($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertEquals(0, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d')));
-
- for ($i = 0; $i < 10; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 1)));
- }
-
- for ($i = 0; $i < 5; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4)));
- }
-
- $pds->updateTotals(1, date('Y-m-d', strtotime('-2days')));
-
- for ($i = 0; $i < 15; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3)));
- }
-
- for ($i = 0; $i < 25; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2)));
- }
-
- $pds->updateTotals(1, date('Y-m-d', strtotime('-1 day')));
-
- $this->assertNotFalse($ts->close(1));
- $this->assertNotFalse($ts->close(2));
-
- for ($i = 0; $i < 3; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3)));
- }
-
- for ($i = 0; $i < 5; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2)));
- }
-
- for ($i = 0; $i < 4; $i++) {
- $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4)));
- }
-
- $pds->updateTotals(1, date('Y-m-d'));
-
- $this->assertEquals(3, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d')));
- $metrics = $pds->getAggregatedMetrics(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d'));
-
- $this->assertNotEmpty($metrics);
- $this->assertEquals(4, count($metrics));
- $this->assertEquals(5, count($metrics[0]));
- $this->assertEquals('Date', $metrics[0][0]);
- $this->assertEquals('Backlog', $metrics[0][1]);
- $this->assertEquals('Ready', $metrics[0][2]);
- $this->assertEquals('Work in progress', $metrics[0][3]);
- $this->assertEquals('Done', $metrics[0][4]);
-
- $this->assertEquals(date('Y-m-d', strtotime('-2days')), $metrics[1][0]);
- $this->assertEquals(10, $metrics[1][1]);
- $this->assertEquals(0, $metrics[1][2]);
- $this->assertEquals(0, $metrics[1][3]);
- $this->assertEquals(5, $metrics[1][4]);
-
- $this->assertEquals(date('Y-m-d', strtotime('-1day')), $metrics[2][0]);
- $this->assertEquals(10, $metrics[2][1]);
- $this->assertEquals(25, $metrics[2][2]);
- $this->assertEquals(15, $metrics[2][3]);
- $this->assertEquals(5, $metrics[2][4]);
-
- $this->assertEquals(date('Y-m-d'), $metrics[3][0]);
- $this->assertEquals(10, $metrics[3][1]);
- $this->assertEquals(30, $metrics[3][2]);
- $this->assertEquals(18, $metrics[3][3]);
- $this->assertEquals(9, $metrics[3][4]);
+ $projectModel = new Project($this->container);
+ $projectDailyColumnStats = new ProjectDailyColumnStats($this->container);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $this->createTasks(1, 2, 1);
+ $this->createTasks(1, 3, 0);
+
+ $this->createTasks(2, 5, 1);
+ $this->createTasks(2, 8, 1);
+ $this->createTasks(2, 0, 0);
+ $this->createTasks(2, 0, 0);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $this->createTasks(1, 9, 1);
+ $this->createTasks(1, 7, 0);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $this->createTasks(3, 0, 1);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-17');
+
+ $stats = $this->container['db']->table(ProjectDailyColumnStats::TABLE)
+ ->asc('day')
+ ->asc('column_id')
+ ->columns('day', 'project_id', 'column_id', 'total', 'score')
+ ->findAll();
+
+ $expected = array(
+ array(
+ 'day' => '2016-01-16',
+ 'project_id' => 1,
+ 'column_id' => 1,
+ 'total' => 4,
+ 'score' => 11,
+ ),
+ array(
+ 'day' => '2016-01-16',
+ 'project_id' => 1,
+ 'column_id' => 2,
+ 'total' => 4,
+ 'score' => 13,
+ ),
+ array(
+ 'day' => '2016-01-17',
+ 'project_id' => 1,
+ 'column_id' => 1,
+ 'total' => 4,
+ 'score' => 11,
+ ),
+ array(
+ 'day' => '2016-01-17',
+ 'project_id' => 1,
+ 'column_id' => 2,
+ 'total' => 4,
+ 'score' => 13,
+ ),
+ array(
+ 'day' => '2016-01-17',
+ 'project_id' => 1,
+ 'column_id' => 3,
+ 'total' => 1,
+ 'score' => 0,
+ ),
+ );
+
+ $this->assertEquals($expected, $stats);
+ }
+
+ public function testUpdateTotalsWithOnlyOpenTasks()
+ {
+ $configModel = new Config($this->container);
+ $projectModel = new Project($this->container);
+ $projectDailyColumnStats = new ProjectDailyColumnStats($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+ $this->assertTrue($configModel->save(array('cfd_include_closed_tasks' => 0)));
+ $this->container['memoryCache']->flush();
+
+ $this->createTasks(1, 2, 1);
+ $this->createTasks(1, 3, 0);
+
+ $this->createTasks(2, 5, 1);
+ $this->createTasks(2, 8, 1);
+ $this->createTasks(2, 0, 0);
+ $this->createTasks(2, 0, 0);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $this->createTasks(1, 9, 1);
+ $this->createTasks(1, 7, 0);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $this->createTasks(3, 0, 1);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-17');
+
+ $stats = $this->container['db']->table(ProjectDailyColumnStats::TABLE)
+ ->asc('day')
+ ->asc('column_id')
+ ->columns('day', 'project_id', 'column_id', 'total', 'score')
+ ->findAll();
+
+ $expected = array(
+ array(
+ 'day' => '2016-01-16',
+ 'project_id' => 1,
+ 'column_id' => 1,
+ 'total' => 2,
+ 'score' => 11,
+ ),
+ array(
+ 'day' => '2016-01-16',
+ 'project_id' => 1,
+ 'column_id' => 2,
+ 'total' => 2,
+ 'score' => 13,
+ ),
+ array(
+ 'day' => '2016-01-17',
+ 'project_id' => 1,
+ 'column_id' => 1,
+ 'total' => 2,
+ 'score' => 11,
+ ),
+ array(
+ 'day' => '2016-01-17',
+ 'project_id' => 1,
+ 'column_id' => 2,
+ 'total' => 2,
+ 'score' => 13,
+ ),
+ array(
+ 'day' => '2016-01-17',
+ 'project_id' => 1,
+ 'column_id' => 3,
+ 'total' => 1,
+ 'score' => 0,
+ ),
+ );
+
+ $this->assertEquals($expected, $stats);
+ }
+
+ public function testCountDays()
+ {
+ $projectModel = new Project($this->container);
+ $projectDailyColumnStats = new ProjectDailyColumnStats($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $this->createTasks(1, 2, 1);
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+ $this->assertEquals(1, $projectDailyColumnStats->countDays(1, '2016-01-16', '2016-01-17'));
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-17');
+ $this->assertEquals(2, $projectDailyColumnStats->countDays(1, '2016-01-16', '2016-01-17'));
+ }
+
+ public function testGetAggregatedMetrics()
+ {
+ $projectModel = new Project($this->container);
+ $projectDailyColumnStats = new ProjectDailyColumnStats($this->container);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $this->createTasks(1, 2, 1);
+ $this->createTasks(1, 3, 0);
+
+ $this->createTasks(2, 5, 1);
+ $this->createTasks(2, 8, 1);
+ $this->createTasks(2, 0, 0);
+ $this->createTasks(2, 0, 0);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $this->createTasks(1, 9, 1);
+ $this->createTasks(1, 7, 0);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-16');
+
+ $this->createTasks(3, 0, 1);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-17');
+
+ $this->createTasks(2, 1, 1);
+ $this->createTasks(3, 1, 1);
+ $this->createTasks(3, 0, 1);
+
+ $projectDailyColumnStats->updateTotals(1, '2016-01-18');
+
+ $expected = array(
+ array('Date', 'Backlog', 'Ready', 'Work in progress', 'Done'),
+ array('2016-01-16', 4, 4, 0, 0),
+ array('2016-01-17', 4, 4, 1, 0),
+ array('2016-01-18', 4, 5, 3, 0),
+ );
+
+ $this->assertEquals($expected, $projectDailyColumnStats->getAggregatedMetrics(1, '2016-01-16', '2016-01-18'));
+
+ $expected = array(
+ array('Date', 'Backlog', 'Ready', 'Work in progress', 'Done'),
+ array('2016-01-16', 11, 13, 0, 0),
+ array('2016-01-17', 11, 13, 0, 0),
+ array('2016-01-18', 11, 14, 1, 0),
+ );
+
+ $this->assertEquals($expected, $projectDailyColumnStats->getAggregatedMetrics(1, '2016-01-16', '2016-01-18', 'score'));
+ }
+
+ private function createTasks($column_id, $score, $is_active)
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => $column_id, 'score' => $score, 'is_active' => $is_active)));
}
}
diff --git a/tests/units/Model/ProjectDailyStatsTest.php b/tests/units/Model/ProjectDailyStatsTest.php
new file mode 100644
index 00000000..9efdb199
--- /dev/null
+++ b/tests/units/Model/ProjectDailyStatsTest.php
@@ -0,0 +1,48 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Project;
+use Kanboard\Model\ProjectDailyStats;
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskStatus;
+
+class ProjectDailyStatsTest extends Base
+{
+ public function testUpdateTotals()
+ {
+ $p = new Project($this->container);
+ $pds = new ProjectDailyStats($this->container);
+ $tc = new TaskCreation($this->container);
+ $ts = new TaskStatus($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
+ $this->assertEquals(2, $p->create(array('name' => 'UnitTest')));
+
+ $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'date_started' => strtotime('-1 day'))));
+ $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1)));
+ $this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 2)));
+
+ $pds->updateTotals(1, date('Y-m-d', strtotime('-1 day')));
+
+ $this->assertTrue($ts->close(1));
+ $pds->updateTotals(1, date('Y-m-d'));
+
+ $metrics = $pds->getRawMetrics(1, date('Y-m-d', strtotime('-1days')), date('Y-m-d'));
+ $expected = array(
+ array(
+ 'day' => date('Y-m-d', strtotime('-1days')),
+ 'avg_lead_time' => 0,
+ 'avg_cycle_time' => 43200,
+ ),
+ array(
+ 'day' => date('Y-m-d'),
+ 'avg_lead_time' => 0,
+ 'avg_cycle_time' => 43200,
+ )
+ );
+
+ $this->assertEquals($expected, $metrics);
+ }
+}
diff --git a/tests/units/Model/ProjectDuplicationTest.php b/tests/units/Model/ProjectDuplicationTest.php
index e3234dfe..ee5b4ce4 100644
--- a/tests/units/Model/ProjectDuplicationTest.php
+++ b/tests/units/Model/ProjectDuplicationTest.php
@@ -5,17 +5,28 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Model\Action;
use Kanboard\Model\Project;
use Kanboard\Model\Category;
-use Kanboard\Model\ProjectPermission;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\ProjectGroupRole;
use Kanboard\Model\ProjectDuplication;
use Kanboard\Model\User;
+use Kanboard\Model\Group;
+use Kanboard\Model\GroupMember;
use Kanboard\Model\Swimlane;
use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
+use Kanboard\Core\Security\Role;
class ProjectDuplicationTest extends Base
{
- public function testProjectName()
+ public function testGetSelections()
+ {
+ $projectDuplicationModel = new ProjectDuplication($this->container);
+ $this->assertCount(5, $projectDuplicationModel->getOptionalSelection());
+ $this->assertCount(6, $projectDuplicationModel->getPossibleSelection());
+ }
+
+ public function testGetClonedProjectName()
{
$pd = new ProjectDuplication($this->container);
@@ -28,54 +39,142 @@ class ProjectDuplicationTest extends Base
$this->assertEquals(str_repeat('a', 42).' (Clone)', $pd->getClonedProjectName(str_repeat('a', 60)));
}
- public function testCopyProjectWithLongName()
+ public function testClonePublicProject()
{
$p = new Project($this->container);
$pd = new ProjectDuplication($this->container);
- $this->assertEquals(1, $p->create(array('name' => str_repeat('a', 50))));
+ $this->assertEquals(1, $p->create(array('name' => 'Public')));
$this->assertEquals(2, $pd->duplicate(1));
$project = $p->getById(2);
$this->assertNotEmpty($project);
- $this->assertEquals(str_repeat('a', 42).' (Clone)', $project['name']);
+ $this->assertEquals('Public (Clone)', $project['name']);
+ $this->assertEquals(1, $project['is_active']);
+ $this->assertEquals(0, $project['is_private']);
+ $this->assertEquals(0, $project['is_public']);
+ $this->assertEquals(0, $project['owner_id']);
+ $this->assertEmpty($project['token']);
}
- public function testClonePublicProject()
+ public function testClonePrivateProject()
{
$p = new Project($this->container);
$pd = new ProjectDuplication($this->container);
+ $pp = new ProjectUserRole($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'Public')));
+ $this->assertEquals(1, $p->create(array('name' => 'Private', 'is_private' => 1), 1, true));
$this->assertEquals(2, $pd->duplicate(1));
$project = $p->getById(2);
$this->assertNotEmpty($project);
- $this->assertEquals('Public (Clone)', $project['name']);
- $this->assertEquals(0, $project['is_private']);
+ $this->assertEquals('Private (Clone)', $project['name']);
+ $this->assertEquals(1, $project['is_active']);
+ $this->assertEquals(1, $project['is_private']);
$this->assertEquals(0, $project['is_public']);
+ $this->assertEquals(0, $project['owner_id']);
$this->assertEmpty($project['token']);
+
+ $this->assertEquals(Role::PROJECT_MANAGER, $pp->getUserRole(2, 1));
}
- public function testClonePrivateProject()
+ public function testCloneSharedProject()
{
$p = new Project($this->container);
$pd = new ProjectDuplication($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'Private', 'is_private' => 1), 1, true));
+ $this->assertEquals(1, $p->create(array('name' => 'Shared')));
+ $this->assertTrue($p->update(array('id' => 1, 'is_public' => 1, 'token' => 'test')));
+
+ $project = $p->getById(1);
+ $this->assertEquals('test', $project['token']);
+ $this->assertEquals(1, $project['is_public']);
+
$this->assertEquals(2, $pd->duplicate(1));
$project = $p->getById(2);
$this->assertNotEmpty($project);
- $this->assertEquals('Private (Clone)', $project['name']);
- $this->assertEquals(1, $project['is_private']);
+ $this->assertEquals('Shared (Clone)', $project['name']);
+ $this->assertEquals('', $project['token']);
$this->assertEquals(0, $project['is_public']);
- $this->assertEmpty($project['token']);
+ }
- $pp = new ProjectPermission($this->container);
+ public function testCloneInactiveProject()
+ {
+ $p = new Project($this->container);
+ $pd = new ProjectDuplication($this->container);
- $this->assertEquals(array(1 => 'admin'), $pp->getMembers(1));
- $this->assertEquals(array(1 => 'admin'), $pp->getMembers(2));
+ $this->assertEquals(1, $p->create(array('name' => 'Inactive')));
+ $this->assertTrue($p->update(array('id' => 1, 'is_active' => 0)));
+
+ $project = $p->getById(1);
+ $this->assertEquals(0, $project['is_active']);
+
+ $this->assertEquals(2, $pd->duplicate(1));
+
+ $project = $p->getById(2);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('Inactive (Clone)', $project['name']);
+ $this->assertEquals(1, $project['is_active']);
+ }
+
+ public function testCloneProjectWithOwner()
+ {
+ $p = new Project($this->container);
+ $pd = new ProjectDuplication($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'Owner')));
+
+ $project = $p->getById(1);
+ $this->assertEquals(0, $project['owner_id']);
+
+ $this->assertEquals(2, $pd->duplicate(1, array('projectPermission'), 1));
+
+ $project = $p->getById(2);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('Owner (Clone)', $project['name']);
+ $this->assertEquals(1, $project['owner_id']);
+
+ $this->assertEquals(Role::PROJECT_MANAGER, $projectUserRoleModel->getUserRole(2, 1));
+ }
+
+ public function testCloneProjectWithDifferentName()
+ {
+ $p = new Project($this->container);
+ $pd = new ProjectDuplication($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'Owner')));
+
+ $project = $p->getById(1);
+ $this->assertEquals(0, $project['owner_id']);
+
+ $this->assertEquals(2, $pd->duplicate(1, array('projectPermission'), 1, 'Foobar'));
+
+ $project = $p->getById(2);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('Foobar', $project['name']);
+ $this->assertEquals(1, $project['owner_id']);
+ }
+
+ public function testCloneProjectAndForceItToBePrivate()
+ {
+ $p = new Project($this->container);
+ $pd = new ProjectDuplication($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'Owner')));
+
+ $project = $p->getById(1);
+ $this->assertEquals(0, $project['owner_id']);
+ $this->assertEquals(0, $project['is_private']);
+
+ $this->assertEquals(2, $pd->duplicate(1, array('projectPermission'), 1, 'Foobar', true));
+
+ $project = $p->getById(2);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('Foobar', $project['name']);
+ $this->assertEquals(1, $project['owner_id']);
+ $this->assertEquals(1, $project['is_private']);
}
public function testCloneProjectWithCategories()
@@ -97,16 +196,9 @@ class ProjectDuplicationTest extends Base
$this->assertEquals('P1 (Clone)', $project['name']);
$categories = $c->getAll(2);
- $this->assertNotempty($categories);
- $this->assertEquals(3, count($categories));
-
- $this->assertEquals(4, $categories[0]['id']);
+ $this->assertCount(3, $categories);
$this->assertEquals('C1', $categories[0]['name']);
-
- $this->assertEquals(5, $categories[1]['id']);
$this->assertEquals('C2', $categories[1]['name']);
-
- $this->assertEquals(6, $categories[2]['id']);
$this->assertEquals('C3', $categories[2]['name']);
}
@@ -114,38 +206,119 @@ class ProjectDuplicationTest extends Base
{
$p = new Project($this->container);
$c = new Category($this->container);
- $pp = new ProjectPermission($this->container);
+ $pp = new ProjectUserRole($this->container);
$u = new User($this->container);
$pd = new ProjectDuplication($this->container);
- $this->assertEquals(2, $u->create(array('username' => 'unittest1', 'password' => 'unittest')));
- $this->assertEquals(3, $u->create(array('username' => 'unittest2', 'password' => 'unittest')));
- $this->assertEquals(4, $u->create(array('username' => 'unittest3', 'password' => 'unittest')));
+ $this->assertEquals(2, $u->create(array('username' => 'user1')));
+ $this->assertEquals(3, $u->create(array('username' => 'user2')));
+ $this->assertEquals(4, $u->create(array('username' => 'user3')));
$this->assertEquals(1, $p->create(array('name' => 'P1')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->addMember(1, 4));
- $this->assertTrue($pp->addManager(1, 3));
- $this->assertTrue($pp->isMember(1, 2));
- $this->assertTrue($pp->isMember(1, 3));
- $this->assertTrue($pp->isMember(1, 4));
- $this->assertFalse($pp->isManager(1, 2));
- $this->assertTrue($pp->isManager(1, 3));
- $this->assertFalse($pp->isManager(1, 4));
+
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($pp->addUser(1, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(1, 4, Role::PROJECT_VIEWER));
$this->assertEquals(2, $pd->duplicate(1));
+ $this->assertCount(3, $pp->getUsers(2));
+ $this->assertEquals(Role::PROJECT_MANAGER, $pp->getUserRole(2, 2));
+ $this->assertEquals(Role::PROJECT_MEMBER, $pp->getUserRole(2, 3));
+ $this->assertEquals(Role::PROJECT_VIEWER, $pp->getUserRole(2, 4));
+ }
+
+ public function testCloneProjectWithUsersAndOverrideOwner()
+ {
+ $p = new Project($this->container);
+ $c = new Category($this->container);
+ $pp = new ProjectUserRole($this->container);
+ $u = new User($this->container);
+ $pd = new ProjectDuplication($this->container);
+
+ $this->assertEquals(2, $u->create(array('username' => 'user1')));
+ $this->assertEquals(1, $p->create(array('name' => 'P1'), 2));
+
+ $project = $p->getById(1);
+ $this->assertEquals(2, $project['owner_id']);
+
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($pp->addUser(1, 1, Role::PROJECT_MEMBER));
+
+ $this->assertEquals(2, $pd->duplicate(1, array('projectPermission'), 1));
+
+ $this->assertCount(2, $pp->getUsers(2));
+ $this->assertEquals(Role::PROJECT_MANAGER, $pp->getUserRole(2, 2));
+ $this->assertEquals(Role::PROJECT_MANAGER, $pp->getUserRole(2, 1));
+
$project = $p->getById(2);
- $this->assertNotEmpty($project);
- $this->assertEquals('P1 (Clone)', $project['name']);
+ $this->assertEquals(1, $project['owner_id']);
+ }
+
+ public function testCloneTeamProjectToPrivatProject()
+ {
+ $p = new Project($this->container);
+ $c = new Category($this->container);
+ $pp = new ProjectUserRole($this->container);
+ $u = new User($this->container);
+ $pd = new ProjectDuplication($this->container);
+
+ $this->assertEquals(2, $u->create(array('username' => 'user1')));
+ $this->assertEquals(3, $u->create(array('username' => 'user2')));
+ $this->assertEquals(1, $p->create(array('name' => 'P1'), 2));
+
+ $project = $p->getById(1);
+ $this->assertEquals(2, $project['owner_id']);
+ $this->assertEquals(0, $project['is_private']);
+
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($pp->addUser(1, 1, Role::PROJECT_MEMBER));
- $this->assertEquals(3, count($pp->getMembers(2)));
- $this->assertTrue($pp->isMember(2, 2));
- $this->assertTrue($pp->isMember(2, 3));
- $this->assertTrue($pp->isMember(2, 4));
- $this->assertFalse($pp->isManager(2, 2));
- $this->assertTrue($pp->isManager(2, 3));
- $this->assertFalse($pp->isManager(2, 4));
+ $this->assertEquals(2, $pd->duplicate(1, array('projectPermission'), 3, 'My private project', true));
+
+ $this->assertCount(1, $pp->getUsers(2));
+ $this->assertEquals(Role::PROJECT_MANAGER, $pp->getUserRole(2, 3));
+
+ $project = $p->getById(2);
+ $this->assertEquals(3, $project['owner_id']);
+ $this->assertEquals(1, $project['is_private']);
+ }
+
+ public function testCloneProjectWithGroups()
+ {
+ $p = new Project($this->container);
+ $c = new Category($this->container);
+ $pd = new ProjectDuplication($this->container);
+ $userModel = new User($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $projectGroupRoleModel = new ProjectGroupRole($this->container);
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'P1')));
+
+ $this->assertEquals(1, $groupModel->create('G1'));
+ $this->assertEquals(2, $groupModel->create('G2'));
+ $this->assertEquals(3, $groupModel->create('G3'));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3')));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
+ $this->assertTrue($groupMemberModel->addUser(2, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 4));
+
+ $this->assertTrue($projectGroupRoleModel->addGroup(1, 1, Role::PROJECT_MANAGER));
+ $this->assertTrue($projectGroupRoleModel->addGroup(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectGroupRoleModel->addGroup(1, 3, Role::PROJECT_VIEWER));
+
+ $this->assertEquals(2, $pd->duplicate(1));
+
+ $this->assertCount(3, $projectGroupRoleModel->getGroups(2));
+ $this->assertEquals(Role::PROJECT_MANAGER, $projectUserRoleModel->getUserRole(2, 2));
+ $this->assertEquals(Role::PROJECT_MEMBER, $projectUserRoleModel->getUserRole(2, 3));
+ $this->assertEquals(Role::PROJECT_VIEWER, $projectUserRoleModel->getUserRole(2, 4));
}
public function testCloneProjectWithActionTaskAssignCurrentUser()
@@ -170,7 +343,7 @@ class ProjectDuplicationTest extends Base
$this->assertNotEmpty($actions);
$this->assertEquals('TaskAssignCurrentUser', $actions[0]['action_name']);
$this->assertNotEmpty($actions[0]['params']);
- $this->assertEquals(6, $actions[0]['params'][0]['value']);
+ $this->assertEquals(6, $actions[0]['params']['column_id']);
}
public function testCloneProjectWithActionTaskAssignColorCategory()
@@ -200,11 +373,11 @@ class ProjectDuplicationTest extends Base
$this->assertNotEmpty($actions);
$this->assertEquals('TaskAssignColorCategory', $actions[0]['action_name']);
$this->assertNotEmpty($actions[0]['params']);
- $this->assertEquals('blue', $actions[0]['params'][0]['value']);
- $this->assertEquals(5, $actions[0]['params'][1]['value']);
+ $this->assertEquals('blue', $actions[0]['params']['color_id']);
+ $this->assertEquals(5, $actions[0]['params']['category_id']);
}
- public function testCloneProjectWithSwimlanesAndTasks()
+ public function testCloneProjectWithSwimlanes()
{
$p = new Project($this->container);
$pd = new ProjectDuplication($this->container);
@@ -212,31 +385,22 @@ class ProjectDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'P1')));
+ $this->assertEquals(1, $p->create(array('name' => 'P1', 'default_swimlane' => 'New Default')));
// create initial swimlanes
$this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'S1')));
$this->assertEquals(2, $s->create(array('project_id' => 1, 'name' => 'S2')));
$this->assertEquals(3, $s->create(array('project_id' => 1, 'name' => 'S3')));
- $default_swimlane1 = $s->getDefault(1);
- $default_swimlane1['default_swimlane'] = 'New Default';
+ // create initial tasks
+ $this->assertEquals(1, $tc->create(array('title' => 'T0', 'project_id' => 1, 'swimlane_id' => 0)));
+ $this->assertEquals(2, $tc->create(array('title' => 'T1', 'project_id' => 1, 'swimlane_id' => 1)));
+ $this->assertEquals(3, $tc->create(array('title' => 'T2', 'project_id' => 1, 'swimlane_id' => 2)));
+ $this->assertEquals(4, $tc->create(array('title' => 'T3', 'project_id' => 1, 'swimlane_id' => 3)));
- $this->assertTrue($s->updateDefault($default_swimlane1));
-
- //create initial tasks
- $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1)));
- $this->assertEquals(2, $tc->create(array('title' => 'T2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1)));
- $this->assertEquals(3, $tc->create(array('title' => 'T3', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1)));
-
- $this->assertNotFalse($pd->duplicate(1, array('category', 'action', 'swimlane', 'task')));
- $project = $p->getByName('P1 (Clone)');
- $this->assertNotFalse($project);
- $project_id = $project['id'];
-
- // Check if Swimlanes have been duplicated
- $swimlanes = $s->getAll($project_id);
+ $this->assertEquals(2, $pd->duplicate(1, array('category', 'swimlane')));
+ $swimlanes = $s->getAll(2);
$this->assertCount(3, $swimlanes);
$this->assertEquals(4, $swimlanes[0]['id']);
$this->assertEquals('S1', $swimlanes[0]['name']);
@@ -244,29 +408,15 @@ class ProjectDuplicationTest extends Base
$this->assertEquals('S2', $swimlanes[1]['name']);
$this->assertEquals(6, $swimlanes[2]['id']);
$this->assertEquals('S3', $swimlanes[2]['name']);
- $new_default = $s->getDefault($project_id);
- $this->assertEquals('New Default', $new_default['default_swimlane']);
-
- // Check if Tasks have been duplicated
- $tasks = $tf->getAll($project_id);
+ $swimlane = $s->getDefault(2);
+ $this->assertEquals('New Default', $swimlane['default_swimlane']);
- $this->assertCount(3, $tasks);
- // $this->assertEquals(4, $tasks[0]['id']);
- $this->assertEquals('T1', $tasks[0]['title']);
- // $this->assertEquals(5, $tasks[1]['id']);
- $this->assertEquals('T2', $tasks[1]['title']);
- // $this->assertEquals(6, $tasks[2]['id']);
- $this->assertEquals('T3', $tasks[2]['title']);
-
- $p->remove($project_id);
-
- $this->assertFalse($p->exists($project_id));
- $this->assertCount(0, $s->getAll($project_id));
- $this->assertCount(0, $tf->getAll($project_id));
+ // Check if tasks are NOT been duplicated
+ $this->assertCount(0, $tf->getAll(2));
}
- public function testCloneProjectWithSwimlanes()
+ public function testCloneProjectWithTasks()
{
$p = new Project($this->container);
$pd = new ProjectDuplication($this->container);
@@ -276,43 +426,22 @@ class ProjectDuplicationTest extends Base
$this->assertEquals(1, $p->create(array('name' => 'P1')));
- // create initial swimlanes
- $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'S1')));
- $this->assertEquals(2, $s->create(array('project_id' => 1, 'name' => 'S2')));
- $this->assertEquals(3, $s->create(array('project_id' => 1, 'name' => 'S3')));
-
- $default_swimlane1 = $s->getDefault(1);
- $default_swimlane1['default_swimlane'] = 'New Default';
+ // create initial tasks
+ $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'column_id' => 1)));
+ $this->assertEquals(2, $tc->create(array('title' => 'T2', 'project_id' => 1, 'column_id' => 2)));
+ $this->assertEquals(3, $tc->create(array('title' => 'T3', 'project_id' => 1, 'column_id' => 3)));
- $this->assertTrue($s->updateDefault($default_swimlane1));
-
- //create initial tasks
- $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1)));
- $this->assertEquals(2, $tc->create(array('title' => 'T2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1)));
- $this->assertEquals(3, $tc->create(array('title' => 'T3', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1)));
-
- $this->assertNotFalse($pd->duplicate(1, array('category', 'action', 'swimlane')));
- $project = $p->getByName('P1 (Clone)');
- $this->assertNotFalse($project);
- $project_id = $project['id'];
-
- $swimlanes = $s->getAll($project_id);
-
- $this->assertCount(3, $swimlanes);
- $this->assertEquals(4, $swimlanes[0]['id']);
- $this->assertEquals('S1', $swimlanes[0]['name']);
- $this->assertEquals(5, $swimlanes[1]['id']);
- $this->assertEquals('S2', $swimlanes[1]['name']);
- $this->assertEquals(6, $swimlanes[2]['id']);
- $this->assertEquals('S3', $swimlanes[2]['name']);
- $new_default = $s->getDefault($project_id);
- $this->assertEquals('New Default', $new_default['default_swimlane']);
+ $this->assertEquals(2, $pd->duplicate(1, array('category', 'action', 'task')));
- // Check if Tasks have NOT been duplicated
- $this->assertCount(0, $tf->getAll($project_id));
+ // Check if Tasks have been duplicated
+ $tasks = $tf->getAll(2);
+ $this->assertCount(3, $tasks);
+ $this->assertEquals('T1', $tasks[0]['title']);
+ $this->assertEquals('T2', $tasks[1]['title']);
+ $this->assertEquals('T3', $tasks[2]['title']);
}
- public function testCloneProjectWithTasks()
+ public function testCloneProjectWithSwimlanesAndTasks()
{
$p = new Project($this->container);
$pd = new ProjectDuplication($this->container);
@@ -320,40 +449,39 @@ class ProjectDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
- $this->assertEquals(1, $p->create(array('name' => 'P1')));
+ $this->assertEquals(1, $p->create(array('name' => 'P1', 'default_swimlane' => 'New Default')));
// create initial swimlanes
$this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'S1')));
$this->assertEquals(2, $s->create(array('project_id' => 1, 'name' => 'S2')));
$this->assertEquals(3, $s->create(array('project_id' => 1, 'name' => 'S3')));
- $default_swimlane1 = $s->getDefault(1);
- $default_swimlane1['default_swimlane'] = 'New Default';
-
- $this->assertTrue($s->updateDefault($default_swimlane1));
-
- //create initial tasks
+ // create initial tasks
$this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1)));
$this->assertEquals(2, $tc->create(array('title' => 'T2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1)));
$this->assertEquals(3, $tc->create(array('title' => 'T3', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1)));
- $this->assertNotFalse($pd->duplicate(1, array('category', 'action', 'task')));
- $project = $p->getByName('P1 (Clone)');
- $this->assertNotFalse($project);
- $project_id = $project['id'];
+ $this->assertEquals(2, $pd->duplicate(1, array('projectPermission', 'swimlane', 'task')));
+
+ // Check if Swimlanes have been duplicated
+ $swimlanes = $s->getAll(2);
+ $this->assertCount(3, $swimlanes);
+ $this->assertEquals(4, $swimlanes[0]['id']);
+ $this->assertEquals('S1', $swimlanes[0]['name']);
+ $this->assertEquals(5, $swimlanes[1]['id']);
+ $this->assertEquals('S2', $swimlanes[1]['name']);
+ $this->assertEquals(6, $swimlanes[2]['id']);
+ $this->assertEquals('S3', $swimlanes[2]['name']);
- // Check if Swimlanes have NOT been duplicated
- $this->assertCount(0, $s->getAll($project_id));
+ $swimlane = $s->getDefault(2);
+ $this->assertEquals('New Default', $swimlane['default_swimlane']);
// Check if Tasks have been duplicated
- $tasks = $tf->getAll($project_id);
+ $tasks = $tf->getAll(2);
$this->assertCount(3, $tasks);
- //$this->assertEquals(4, $tasks[0]['id']);
$this->assertEquals('T1', $tasks[0]['title']);
- //$this->assertEquals(5, $tasks[1]['id']);
$this->assertEquals('T2', $tasks[1]['title']);
- //$this->assertEquals(6, $tasks[2]['id']);
$this->assertEquals('T3', $tasks[2]['title']);
}
}
diff --git a/tests/units/Model/ProjectFileTest.php b/tests/units/Model/ProjectFileTest.php
new file mode 100644
index 00000000..d9b37fbe
--- /dev/null
+++ b/tests/units/Model/ProjectFileTest.php
@@ -0,0 +1,311 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\ProjectFile;
+use Kanboard\Model\Project;
+
+class ProjectFileTest extends Base
+{
+ public function testCreation()
+ {
+ $projectModel = new Project($this->container);
+ $fileModel = new ProjectFile($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'test', '/tmp/foo', 10));
+
+ $file = $fileModel->getById(1);
+ $this->assertEquals('test', $file['name']);
+ $this->assertEquals('/tmp/foo', $file['path']);
+ $this->assertEquals(0, $file['is_image']);
+ $this->assertEquals(1, $file['project_id']);
+ $this->assertEquals(time(), $file['date'], '', 2);
+ $this->assertEquals(0, $file['user_id']);
+ $this->assertEquals(10, $file['size']);
+
+ $this->assertEquals(2, $fileModel->create(1, 'test2.png', '/tmp/foobar', 10));
+
+ $file = $fileModel->getById(2);
+ $this->assertEquals('test2.png', $file['name']);
+ $this->assertEquals('/tmp/foobar', $file['path']);
+ $this->assertEquals(1, $file['is_image']);
+ }
+
+ public function testCreationWithFileNameTooLong()
+ {
+ $projectModel = new Project($this->container);
+ $fileModel = new ProjectFile($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $this->assertNotFalse($fileModel->create(1, 'test', '/tmp/foo', 10));
+ $this->assertNotFalse($fileModel->create(1, str_repeat('a', 1000), '/tmp/foo', 10));
+
+ $files = $fileModel->getAll(1);
+ $this->assertNotEmpty($files);
+ $this->assertCount(2, $files);
+
+ $this->assertEquals(str_repeat('a', 255), $files[0]['name']);
+ $this->assertEquals('test', $files[1]['name']);
+ }
+
+ public function testCreationWithSessionOpen()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $fileModel = new ProjectFile($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'test', '/tmp/foo', 10));
+
+ $file = $fileModel->getById(1);
+ $this->assertEquals('test', $file['name']);
+ $this->assertEquals(1, $file['user_id']);
+ }
+
+ public function testGetAll()
+ {
+ $projectModel = new Project($this->container);
+ $fileModel = new ProjectFile($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $this->assertEquals(1, $fileModel->create(1, 'B.pdf', '/tmp/foo', 10));
+ $this->assertEquals(2, $fileModel->create(1, 'A.png', '/tmp/foo', 10));
+ $this->assertEquals(3, $fileModel->create(1, 'D.doc', '/tmp/foo', 10));
+ $this->assertEquals(4, $fileModel->create(1, 'C.JPG', '/tmp/foo', 10));
+
+ $fileModeliles = $fileModel->getAll(1);
+ $this->assertNotEmpty($fileModeliles);
+ $this->assertCount(4, $fileModeliles);
+ $this->assertEquals('A.png', $fileModeliles[0]['name']);
+ $this->assertEquals('B.pdf', $fileModeliles[1]['name']);
+ $this->assertEquals('C.JPG', $fileModeliles[2]['name']);
+ $this->assertEquals('D.doc', $fileModeliles[3]['name']);
+
+ $fileModeliles = $fileModel->getAllImages(1);
+ $this->assertNotEmpty($fileModeliles);
+ $this->assertCount(2, $fileModeliles);
+ $this->assertEquals('A.png', $fileModeliles[0]['name']);
+ $this->assertEquals('C.JPG', $fileModeliles[1]['name']);
+
+ $fileModeliles = $fileModel->getAllDocuments(1);
+ $this->assertNotEmpty($fileModeliles);
+ $this->assertCount(2, $fileModeliles);
+ $this->assertEquals('B.pdf', $fileModeliles[0]['name']);
+ $this->assertEquals('D.doc', $fileModeliles[1]['name']);
+ }
+
+ public function testGetThumbnailPath()
+ {
+ $fileModel = new ProjectFile($this->container);
+ $this->assertEquals('thumbnails'.DIRECTORY_SEPARATOR.'test', $fileModel->getThumbnailPath('test'));
+ }
+
+ public function testGeneratePath()
+ {
+ $fileModel = new ProjectFile($this->container);
+
+ $this->assertStringStartsWith('projects'.DIRECTORY_SEPARATOR.'34'.DIRECTORY_SEPARATOR, $fileModel->generatePath(34, 'test.png'));
+ $this->assertNotEquals($fileModel->generatePath(34, 'test1.png'), $fileModel->generatePath(34, 'test2.png'));
+ }
+
+ public function testUploadFiles()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\ProjectFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $files = array(
+ 'name' => array(
+ 'file1.png',
+ 'file2.doc',
+ ),
+ 'tmp_name' => array(
+ '/tmp/phpYzdqkD',
+ '/tmp/phpeEwEWG',
+ ),
+ 'error' => array(
+ UPLOAD_ERR_OK,
+ UPLOAD_ERR_OK,
+ ),
+ 'size' => array(
+ 123,
+ 456,
+ ),
+ );
+
+ $fileModel
+ ->expects($this->once())
+ ->method('generateThumbnailFromFile');
+
+ $this->container['objectStorage']
+ ->expects($this->at(0))
+ ->method('moveUploadedFile')
+ ->with($this->equalTo('/tmp/phpYzdqkD'), $this->anything());
+
+ $this->container['objectStorage']
+ ->expects($this->at(1))
+ ->method('moveUploadedFile')
+ ->with($this->equalTo('/tmp/phpeEwEWG'), $this->anything());
+
+ $this->assertTrue($fileModel->uploadFiles(1, $files));
+
+ $files = $fileModel->getAll(1);
+ $this->assertCount(2, $files);
+
+ $this->assertEquals(1, $files[0]['id']);
+ $this->assertEquals('file1.png', $files[0]['name']);
+ $this->assertEquals(1, $files[0]['is_image']);
+ $this->assertEquals(1, $files[0]['project_id']);
+ $this->assertEquals(0, $files[0]['user_id']);
+ $this->assertEquals(123, $files[0]['size']);
+ $this->assertEquals(time(), $files[0]['date'], '', 2);
+
+ $this->assertEquals(2, $files[1]['id']);
+ $this->assertEquals('file2.doc', $files[1]['name']);
+ $this->assertEquals(0, $files[1]['is_image']);
+ $this->assertEquals(1, $files[1]['project_id']);
+ $this->assertEquals(0, $files[1]['user_id']);
+ $this->assertEquals(456, $files[1]['size']);
+ $this->assertEquals(time(), $files[1]['date'], '', 2);
+ }
+
+ public function testUploadFilesWithEmptyFiles()
+ {
+ $fileModel = new ProjectFile($this->container);
+ $this->assertFalse($fileModel->uploadFiles(1, array()));
+ }
+
+ public function testUploadFilesWithUploadError()
+ {
+ $files = array(
+ 'name' => array(
+ 'file1.png',
+ 'file2.doc',
+ ),
+ 'tmp_name' => array(
+ '',
+ '/tmp/phpeEwEWG',
+ ),
+ 'error' => array(
+ UPLOAD_ERR_CANT_WRITE,
+ UPLOAD_ERR_OK,
+ ),
+ 'size' => array(
+ 123,
+ 456,
+ ),
+ );
+
+ $fileModel = new ProjectFile($this->container);
+ $this->assertFalse($fileModel->uploadFiles(1, $files));
+ }
+
+ public function testUploadFilesWithObjectStorageError()
+ {
+ $files = array(
+ 'name' => array(
+ 'file1.csv',
+ 'file2.doc',
+ ),
+ 'tmp_name' => array(
+ '/tmp/phpYzdqkD',
+ '/tmp/phpeEwEWG',
+ ),
+ 'error' => array(
+ UPLOAD_ERR_OK,
+ UPLOAD_ERR_OK,
+ ),
+ 'size' => array(
+ 123,
+ 456,
+ ),
+ );
+
+ $this->container['objectStorage']
+ ->expects($this->at(0))
+ ->method('moveUploadedFile')
+ ->with($this->equalTo('/tmp/phpYzdqkD'), $this->anything())
+ ->will($this->throwException(new \Kanboard\Core\ObjectStorage\ObjectStorageException('test')));
+
+ $fileModel = new ProjectFile($this->container);
+ $this->assertFalse($fileModel->uploadFiles(1, $files));
+ }
+
+ public function testUploadFileContent()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\ProjectFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+ $data = 'test';
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('put')
+ ->with($this->anything(), $this->equalTo($data));
+
+ $this->assertEquals(1, $fileModel->uploadContent(1, 'test.doc', base64_encode($data)));
+
+ $files = $fileModel->getAll(1);
+ $this->assertCount(1, $files);
+
+ $this->assertEquals(1, $files[0]['id']);
+ $this->assertEquals('test.doc', $files[0]['name']);
+ $this->assertEquals(0, $files[0]['is_image']);
+ $this->assertEquals(1, $files[0]['project_id']);
+ $this->assertEquals(0, $files[0]['user_id']);
+ $this->assertEquals(4, $files[0]['size']);
+ $this->assertEquals(time(), $files[0]['date'], '', 2);
+ }
+
+ public function testUploadImageContent()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\ProjectFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+ $data = 'test';
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+
+ $fileModel
+ ->expects($this->once())
+ ->method('generateThumbnailFromFile');
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('put')
+ ->with($this->anything(), $this->equalTo($data));
+
+ $this->assertEquals(1, $fileModel->uploadContent(1, 'test.png', base64_encode($data)));
+
+ $files = $fileModel->getAll(1);
+ $this->assertCount(1, $files);
+
+ $this->assertEquals(1, $files[0]['id']);
+ $this->assertEquals('test.png', $files[0]['name']);
+ $this->assertEquals(1, $files[0]['is_image']);
+ $this->assertEquals(1, $files[0]['project_id']);
+ $this->assertEquals(0, $files[0]['user_id']);
+ $this->assertEquals(4, $files[0]['size']);
+ $this->assertEquals(time(), $files[0]['date'], '', 2);
+ }
+}
diff --git a/tests/units/Model/ProjectGroupRoleTest.php b/tests/units/Model/ProjectGroupRoleTest.php
new file mode 100644
index 00000000..e38e812a
--- /dev/null
+++ b/tests/units/Model/ProjectGroupRoleTest.php
@@ -0,0 +1,401 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Project;
+use Kanboard\Model\User;
+use Kanboard\Model\Group;
+use Kanboard\Model\GroupMember;
+use Kanboard\Model\ProjectGroupRole;
+use Kanboard\Core\Security\Role;
+
+class ProjectGroupRoleTest extends Base
+{
+ public function testGetUserRole()
+ {
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 1));
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+
+ $this->assertEquals(Role::PROJECT_VIEWER, $groupRoleModel->getUserRole(1, 1));
+ $this->assertEquals('', $groupRoleModel->getUserRole(1, 2));
+ }
+
+ public function testAddGroup()
+ {
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $groupModel->create('Test'));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertFalse($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+
+ $groups = $groupRoleModel->getGroups(1);
+ $this->assertCount(1, $groups);
+ $this->assertEquals(1, $groups[0]['id']);
+ $this->assertEquals('Test', $groups[0]['name']);
+ $this->assertEquals(Role::PROJECT_VIEWER, $groups[0]['role']);
+ }
+
+ public function testRemoveGroup()
+ {
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $groupModel->create('Test'));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->removeGroup(1, 1));
+ $this->assertFalse($groupRoleModel->removeGroup(1, 1));
+
+ $this->assertEmpty($groupRoleModel->getGroups(1));
+ }
+
+ public function testChangeRole()
+ {
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $groupModel->create('Test'));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->changeGroupRole(1, 1, Role::PROJECT_MANAGER));
+
+ $groups = $groupRoleModel->getGroups(1);
+ $this->assertCount(1, $groups);
+ $this->assertEquals(1, $groups[0]['id']);
+ $this->assertEquals('Test', $groups[0]['name']);
+ $this->assertEquals(Role::PROJECT_MANAGER, $groups[0]['role']);
+ }
+
+ public function testGetGroups()
+ {
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $groups = $groupRoleModel->getGroups(1);
+ $this->assertCount(3, $groups);
+
+ $this->assertEquals(3, $groups[0]['id']);
+ $this->assertEquals('Group A', $groups[0]['name']);
+ $this->assertEquals(Role::PROJECT_MANAGER, $groups[0]['role']);
+
+ $this->assertEquals(2, $groups[1]['id']);
+ $this->assertEquals('Group B', $groups[1]['name']);
+ $this->assertEquals(Role::PROJECT_MEMBER, $groups[1]['role']);
+
+ $this->assertEquals(1, $groups[2]['id']);
+ $this->assertEquals('Group C', $groups[2]['name']);
+ $this->assertEquals(Role::PROJECT_VIEWER, $groups[2]['role']);
+ }
+
+ public function testGetUsers()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group C'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $users = $groupRoleModel->getUsers(2);
+ $this->assertCount(0, $users);
+
+ $users = $groupRoleModel->getUsers(1);
+ $this->assertCount(3, $users);
+
+ $this->assertEquals(2, $users[0]['id']);
+ $this->assertEquals('user 1', $users[0]['username']);
+ $this->assertEquals('User #1', $users[0]['name']);
+ $this->assertEquals(Role::PROJECT_MANAGER, $users[0]['role']);
+
+ $this->assertEquals(3, $users[1]['id']);
+ $this->assertEquals('user 2', $users[1]['username']);
+ $this->assertEquals('', $users[1]['name']);
+ $this->assertEquals(Role::PROJECT_MEMBER, $users[1]['role']);
+
+ $this->assertEquals(4, $users[2]['id']);
+ $this->assertEquals('user 3', $users[2]['username']);
+ $this->assertEquals('', $users[2]['name']);
+ $this->assertEquals(Role::PROJECT_VIEWER, $users[2]['role']);
+ }
+
+ public function testGetAssignableUsers()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group C'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $users = $groupRoleModel->getAssignableUsers(2);
+ $this->assertCount(0, $users);
+
+ $users = $groupRoleModel->getAssignableUsers(1);
+ $this->assertCount(2, $users);
+
+ $this->assertEquals(2, $users[0]['id']);
+ $this->assertEquals('user 1', $users[0]['username']);
+ $this->assertEquals('User #1', $users[0]['name']);
+
+ $this->assertEquals(3, $users[1]['id']);
+ $this->assertEquals('user 2', $users[1]['username']);
+ $this->assertEquals('', $users[1]['name']);
+ }
+
+ public function testGetAssignableUsersWithDisabledUsers()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2', 'is_active' => 0)));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group C'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $users = $groupRoleModel->getAssignableUsers(2);
+ $this->assertCount(0, $users);
+
+ $users = $groupRoleModel->getAssignableUsers(1);
+ $this->assertCount(1, $users);
+
+ $this->assertEquals(2, $users[0]['id']);
+ $this->assertEquals('user 1', $users[0]['username']);
+ $this->assertEquals('User #1', $users[0]['name']);
+ }
+
+ public function testGetProjectsByUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $projects = $groupRoleModel->getProjectsByUser(2);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $groupRoleModel->getProjectsByUser(3);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $groupRoleModel->getProjectsByUser(4);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $groupRoleModel->getProjectsByUser(5);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+ }
+
+ public function testGetActiveProjectsByUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1', 'is_active' => 0)));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $projects = $groupRoleModel->getProjectsByUser(2, array(Project::ACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $groupRoleModel->getProjectsByUser(3, array(Project::ACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $groupRoleModel->getProjectsByUser(4, array(Project::ACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $groupRoleModel->getProjectsByUser(5, array(Project::ACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+ }
+
+ public function testGetInactiveProjectsByUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1', 'is_active' => 0)));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $projects = $groupRoleModel->getProjectsByUser(2, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $groupRoleModel->getProjectsByUser(3, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $groupRoleModel->getProjectsByUser(4, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $groupRoleModel->getProjectsByUser(5, array(Project::INACTIVE));
+ $this->assertCount(0, $projects);
+ }
+
+ public function testUserInMultipleGroupsShouldReturnHighestRole()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'My user')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 1));
+ $this->assertTrue($groupMemberModel->addUser(2, 1));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 2, Role::PROJECT_MANAGER));
+
+ $this->assertEquals(Role::PROJECT_MANAGER, $groupRoleModel->getUserRole(1, 1));
+ }
+}
diff --git a/tests/units/Model/ProjectMetadataTest.php b/tests/units/Model/ProjectMetadataTest.php
index 43d66b7b..5814987d 100644
--- a/tests/units/Model/ProjectMetadataTest.php
+++ b/tests/units/Model/ProjectMetadataTest.php
@@ -30,6 +30,11 @@ class ProjectMetadataTest extends Base
$this->assertEquals(array('key1' => 'value2'), $pm->getAll(1));
$this->assertEquals(array('key1' => 'value1', 'key2' => 'value2'), $pm->getAll(2));
+
+ $this->assertTrue($pm->remove(2, 'key1'));
+ $this->assertFalse($pm->remove(2, 'key1'));
+
+ $this->assertEquals(array('key2' => 'value2'), $pm->getAll(2));
}
public function testAutomaticRemove()
diff --git a/tests/units/Model/ProjectPermissionTest.php b/tests/units/Model/ProjectPermissionTest.php
index 1ee63a76..10fcdcc2 100644
--- a/tests/units/Model/ProjectPermissionTest.php
+++ b/tests/units/Model/ProjectPermissionTest.php
@@ -2,286 +2,317 @@
require_once __DIR__.'/../Base.php';
-use Kanboard\Model\Project;
use Kanboard\Model\ProjectPermission;
+use Kanboard\Model\Project;
use Kanboard\Model\User;
+use Kanboard\Model\Group;
+use Kanboard\Model\GroupMember;
+use Kanboard\Model\ProjectGroupRole;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Core\Security\Role;
class ProjectPermissionTest extends Base
{
- public function testAllowEverybody()
+ public function testFindByUsernames()
{
- $user = new User($this->container);
- $this->assertNotFalse($user->create(array('username' => 'unittest#1', 'password' => 'unittest')));
- $this->assertNotFalse($user->create(array('username' => 'unittest#2', 'password' => 'unittest')));
-
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertFalse($pp->isEverybodyAllowed(1));
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertFalse($pp->isUserAllowed(1, 2));
- $this->assertFalse($pp->isUserAllowed(1, 3));
- $this->assertEquals(array(), $pp->getMembers(1));
- $this->assertEquals(array('Unassigned'), $pp->getMemberList(1));
-
- $this->assertEmpty($pp->getMemberProjects(1));
- $this->assertEmpty($pp->getMemberProjects(2));
- $this->assertEmpty($pp->getMemberProjects(3));
-
- $this->assertEmpty($pp->getMemberProjectIds(1));
- $this->assertEmpty($pp->getMemberProjectIds(2));
- $this->assertEmpty($pp->getMemberProjectIds(3));
-
- $this->assertEmpty($pp->getActiveMemberProjectIds(1));
- $this->assertEmpty($pp->getActiveMemberProjectIds(2));
- $this->assertEmpty($pp->getActiveMemberProjectIds(3));
-
- $this->assertEmpty($pp->getActiveMemberProjects(1));
- $this->assertEmpty($pp->getActiveMemberProjects(2));
- $this->assertEmpty($pp->getActiveMemberProjects(3));
-
- $this->assertTrue($p->update(array('id' => 1, 'is_everybody_allowed' => 1)));
- $this->assertTrue($pp->isEverybodyAllowed(1));
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertTrue($pp->isUserAllowed(1, 2));
- $this->assertTrue($pp->isUserAllowed(1, 3));
- $this->assertEquals(array('1' => 'admin', '2' => 'unittest#1', '3' => 'unittest#2'), $pp->getMembers(1));
- $this->assertEquals(array('Unassigned', '1' => 'admin', '2' => 'unittest#1', '3' => 'unittest#2'), $pp->getMemberList(1));
-
- $this->assertNotEmpty($pp->getMemberProjects(1));
- $this->assertNotEmpty($pp->getMemberProjects(2));
- $this->assertNotEmpty($pp->getMemberProjects(3));
-
- $this->assertNotEmpty($pp->getMemberProjectIds(1));
- $this->assertNotEmpty($pp->getMemberProjectIds(2));
- $this->assertNotEmpty($pp->getMemberProjectIds(3));
-
- $this->assertNotEmpty($pp->getActiveMemberProjectIds(1));
- $this->assertNotEmpty($pp->getActiveMemberProjectIds(2));
- $this->assertNotEmpty($pp->getActiveMemberProjectIds(3));
-
- $this->assertNotEmpty($pp->getActiveMemberProjects(1));
- $this->assertNotEmpty($pp->getActiveMemberProjects(2));
- $this->assertNotEmpty($pp->getActiveMemberProjects(3));
-
- $this->assertTrue($p->disable(1));
-
- $this->assertEmpty($pp->getActiveMemberProjectIds(1));
- $this->assertEmpty($pp->getActiveMemberProjectIds(2));
- $this->assertEmpty($pp->getActiveMemberProjectIds(3));
-
- $this->assertEmpty($pp->getActiveMemberProjects(1));
- $this->assertEmpty($pp->getActiveMemberProjects(2));
- $this->assertEmpty($pp->getActiveMemberProjects(3));
- }
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermissionModel = new ProjectPermission($this->container);
- public function testDisallowEverybody()
- {
- // We create a regular user
- $user = new User($this->container);
- $user->create(array('username' => 'unittest', 'password' => 'unittest'));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3')));
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MANAGER));
- $this->assertEmpty($pp->getMembers(1)); // Nobody is specified for the given project
- $this->assertTrue($pp->isUserAllowed(1, 1)); // Admin should be allowed
- $this->assertFalse($pp->isUserAllowed(1, 2)); // Regular user should be denied
+ $this->assertEquals(array('user1', 'user2'), $projectPermissionModel->findUsernames(1, 'us'));
+ $this->assertEmpty($projectPermissionModel->findUsernames(1, 'a'));
+ $this->assertEmpty($projectPermissionModel->findUsernames(2, 'user'));
}
- public function testAllowUser()
+ public function testGetQueryByRole()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $user = new User($this->container);
-
- $this->assertNotFalse($user->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create a project
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
-
- $this->assertEmpty($pp->getMemberProjects(1));
- $this->assertEmpty($pp->getMemberProjects(2));
-
- $this->assertEmpty($pp->getMemberProjectIds(1));
- $this->assertEmpty($pp->getMemberProjectIds(2));
-
- $this->assertEmpty($pp->getActiveMemberProjectIds(1));
- $this->assertEmpty($pp->getActiveMemberProjectIds(2));
-
- $this->assertEmpty($pp->getActiveMemberProjects(1));
- $this->assertEmpty($pp->getActiveMemberProjects(2));
-
- // We allow the admin user
- $this->assertTrue($pp->addMember(1, 1));
- $this->assertTrue($pp->addMember(1, 2));
-
- // Non-existant project
- $this->assertFalse($pp->addMember(50, 1));
-
- // Non-existant user
- $this->assertFalse($pp->addMember(1, 50));
-
- // Both users should be allowed
- $this->assertEquals(array('1' => 'admin', '2' => 'unittest'), $pp->getMembers(1));
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertTrue($pp->isUserAllowed(1, 2));
-
- $this->assertNotEmpty($pp->getMemberProjects(1));
- $this->assertNotEmpty($pp->getMemberProjects(2));
-
- $this->assertNotEmpty($pp->getMemberProjectIds(1));
- $this->assertNotEmpty($pp->getMemberProjectIds(2));
-
- $this->assertNotEmpty($pp->getActiveMemberProjectIds(1));
- $this->assertNotEmpty($pp->getActiveMemberProjectIds(2));
-
- $this->assertNotEmpty($pp->getActiveMemberProjects(1));
- $this->assertNotEmpty($pp->getActiveMemberProjects(2));
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+ $this->assertEquals(3, $projectModel->create(array('name' => 'Project 3')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(1, 4, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 5, Role::PROJECT_MEMBER));
+
+ $this->assertTrue($userRoleModel->addUser(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(2, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(2, 5, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($userRoleModel->addUser(3, 4, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(3, 5, Role::PROJECT_VIEWER));
+
+ $this->assertEmpty($projectPermission->getQueryByRole(array(), Role::PROJECT_MANAGER)->findAll());
+
+ $users = $projectPermission->getQueryByRole(array(1, 2), Role::PROJECT_MANAGER)->findAll();
+ $this->assertCount(3, $users);
+ $this->assertEquals('user 1', $users[0]['username']);
+ $this->assertEquals('Project 1', $users[0]['project_name']);
+ $this->assertEquals('user 2', $users[1]['username']);
+ $this->assertEquals('Project 1', $users[1]['project_name']);
+ $this->assertEquals('user 4', $users[2]['username']);
+ $this->assertEquals('Project 2', $users[2]['project_name']);
+
+ $users = $projectPermission->getQueryByRole(array(1), Role::PROJECT_MANAGER)->findAll();
+ $this->assertCount(2, $users);
+ $this->assertEquals('user 1', $users[0]['username']);
+ $this->assertEquals('Project 1', $users[0]['project_name']);
+ $this->assertEquals('user 2', $users[1]['username']);
+ $this->assertEquals('Project 1', $users[1]['project_name']);
+
+ $users = $projectPermission->getQueryByRole(array(1, 2, 3), Role::PROJECT_MEMBER)->findAll();
+ $this->assertCount(4, $users);
+ $this->assertEquals('user 3', $users[0]['username']);
+ $this->assertEquals('Project 1', $users[0]['project_name']);
+ $this->assertEquals('user 4', $users[1]['username']);
+ $this->assertEquals('Project 1', $users[1]['project_name']);
+ $this->assertEquals('user 1', $users[2]['username']);
+ $this->assertEquals('Project 2', $users[2]['project_name']);
+ $this->assertEquals('user 2', $users[3]['username']);
+ $this->assertEquals('Project 2', $users[3]['project_name']);
+
+ $users = $projectPermission->getQueryByRole(array(1, 2, 3), Role::PROJECT_VIEWER)->findAll();
+ $this->assertCount(1, $users);
+ $this->assertEquals('user 4', $users[0]['username']);
+ $this->assertEquals('Project 3', $users[0]['project_name']);
}
- public function testRevokeUser()
+ public function testEverybodyAllowed()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $user = new User($this->container);
-
- $user->create(array('username' => 'unittest', 'password' => 'unittest'));
-
- // We create a project
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
+ $projectModel = new Project($this->container);
+ $projectPermission = new ProjectPermission($this->container);
- // We revoke our admin user (not existing row)
- $this->assertFalse($pp->revokeMember(1, 1));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2', 'is_everybody_allowed' => 1)));
- // We should have nobody in the users list
- $this->assertEmpty($pp->getMembers(1));
-
- // Only admin is allowed
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertFalse($pp->isUserAllowed(1, 2));
-
- // We allow only the regular user
- $this->assertTrue($pp->addMember(1, 2));
-
- // All users should be allowed (admin and regular)
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertTrue($pp->isUserAllowed(1, 2));
-
- // However, we should have only our regular user in the list
- $this->assertEquals(array('2' => 'unittest'), $pp->getMembers(1));
-
- // We allow our admin, we should have both in the list
- $this->assertTrue($pp->addMember(1, 1));
- $this->assertEquals(array('1' => 'admin', '2' => 'unittest'), $pp->getMembers(1));
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertTrue($pp->isUserAllowed(1, 2));
-
- // We revoke the regular user
- $this->assertTrue($pp->revokeMember(1, 2));
-
- // Only admin should be allowed
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertFalse($pp->isUserAllowed(1, 2));
-
- // We should have only admin in the list
- $this->assertEquals(array('1' => 'admin'), $pp->getMembers(1));
-
- // We revoke the admin user
- $this->assertTrue($pp->revokeMember(1, 1));
- $this->assertEmpty($pp->getMembers(1));
-
- // Only admin should be allowed again
- $this->assertTrue($pp->isUserAllowed(1, 1));
- $this->assertFalse($pp->isUserAllowed(1, 2));
+ $this->assertFalse($projectPermission->isEverybodyAllowed(1));
+ $this->assertTrue($projectPermission->isEverybodyAllowed(2));
}
- public function testManager()
+ public function testIsUserAllowed()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
- $u = new User($this->container);
-
- $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
- $this->assertFalse($pp->isMember(1, 2));
- $this->assertFalse($pp->isManager(1, 2));
-
- $this->assertEquals(2, $p->create(array('name' => 'UnitTest2'), 1, true));
- $this->assertFalse($pp->isMember(2, 2));
- $this->assertFalse($pp->isManager(2, 2));
-
- $this->assertEquals(3, $p->create(array('name' => 'UnitTest3'), 2, true));
- $this->assertTrue($pp->isMember(3, 2));
- $this->assertTrue($pp->isManager(3, 2));
-
- $this->assertEquals(4, $p->create(array('name' => 'UnitTest4')));
-
- $this->assertTrue($pp->addManager(4, 2));
- $this->assertTrue($pp->isMember(4, 2));
- $this->assertTrue($pp->isManager(4, 2));
-
- $this->assertEquals(5, $p->create(array('name' => 'UnitTest5')));
- $this->assertTrue($pp->addMember(5, 2));
- $this->assertTrue($pp->changeRole(5, 2, 1));
- $this->assertTrue($pp->isMember(5, 2));
- $this->assertTrue($pp->isManager(5, 2));
- $this->assertTrue($pp->changeRole(5, 2, 0));
- $this->assertTrue($pp->isMember(5, 2));
- $this->assertFalse($pp->isManager(5, 2));
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 4, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($projectPermission->isUserAllowed(1, 2));
+ $this->assertTrue($projectPermission->isUserAllowed(1, 3));
+ $this->assertTrue($projectPermission->isUserAllowed(1, 4));
+ $this->assertFalse($projectPermission->isUserAllowed(1, 5));
+
+ $this->assertFalse($projectPermission->isUserAllowed(2, 2));
+ $this->assertFalse($projectPermission->isUserAllowed(2, 3));
+ $this->assertFalse($projectPermission->isUserAllowed(2, 4));
+ $this->assertFalse($projectPermission->isUserAllowed(2, 5));
}
- public function testUsersList()
+ public function testIsAssignable()
{
- $p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
-
- $user = new User($this->container);
- $this->assertNotFalse($user->create(array('username' => 'unittest', 'password' => 'unittest')));
-
- // We create project
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
-
- // No restriction, we should have no body
- $this->assertEquals(
- array('Unassigned'),
- $pp->getMemberList(1)
- );
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 4, Role::PROJECT_MANAGER));
+
+ $this->assertFalse($projectPermission->isAssignable(1, 2));
+ $this->assertTrue($projectPermission->isAssignable(1, 3));
+ $this->assertTrue($projectPermission->isAssignable(1, 4));
+ $this->assertFalse($projectPermission->isAssignable(1, 5));
+
+ $this->assertFalse($projectPermission->isAssignable(2, 2));
+ $this->assertFalse($projectPermission->isAssignable(2, 3));
+ $this->assertFalse($projectPermission->isAssignable(2, 4));
+ $this->assertFalse($projectPermission->isAssignable(2, 5));
+ }
- // We allow only the regular user
- $this->assertTrue($pp->addMember(1, 2));
+ public function testIsAssignableWhenUserIsDisabled()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
- $this->assertEquals(
- array(0 => 'Unassigned', 2 => 'unittest'),
- $pp->getMemberList(1)
- );
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2', 'is_active' => 0)));
- // We allow the admin user
- $this->assertTrue($pp->addMember(1, 1));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
- $this->assertEquals(
- array(0 => 'Unassigned', 1 => 'admin', 2 => 'unittest'),
- $pp->getMemberList(1)
- );
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
- // We revoke only the regular user
- $this->assertTrue($pp->revokeMember(1, 2));
+ $this->assertTrue($projectPermission->isAssignable(1, 2));
+ $this->assertFalse($projectPermission->isAssignable(1, 3));
+ }
- $this->assertEquals(
- array(0 => 'Unassigned', 1 => 'admin'),
- $pp->getMemberList(1)
- );
+ public function testIsMember()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 4, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($projectPermission->isMember(1, 2));
+ $this->assertTrue($projectPermission->isMember(1, 3));
+ $this->assertTrue($projectPermission->isMember(1, 4));
+ $this->assertFalse($projectPermission->isMember(1, 5));
+
+ $this->assertFalse($projectPermission->isMember(2, 2));
+ $this->assertFalse($projectPermission->isMember(2, 3));
+ $this->assertFalse($projectPermission->isMember(2, 4));
+ $this->assertFalse($projectPermission->isMember(2, 5));
+ }
- // We revoke only the admin user, we should have everybody
- $this->assertTrue($pp->revokeMember(1, 1));
+ public function testGetActiveProjectIds()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2', 'is_active' => 0)));
+
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(2, 2, Role::PROJECT_VIEWER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_VIEWER));
+
+ $this->assertEmpty($projectPermission->getActiveProjectIds(1));
+ $this->assertEquals(array(1), $projectPermission->getActiveProjectIds(2));
+ $this->assertEquals(array(1), $projectPermission->getActiveProjectIds(3));
+ }
- $this->assertEquals(
- array(0 => 'Unassigned'),
- $pp->getMemberList(1)
- );
+ public function testDuplicate()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+ $this->assertEquals(6, $userModel->create(array('username' => 'user 5', 'name' => 'User #5')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 5, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(1, 6, Role::PROJECT_MEMBER));
+
+ $this->assertTrue($projectPermission->duplicate(1, 2));
+
+ $this->assertCount(2, $userRoleModel->getUsers(2));
+ $this->assertCount(3, $groupRoleModel->getUsers(2));
}
}
diff --git a/tests/units/Model/ProjectTest.php b/tests/units/Model/ProjectTest.php
index f90c0dc1..5478fa40 100644
--- a/tests/units/Model/ProjectTest.php
+++ b/tests/units/Model/ProjectTest.php
@@ -5,12 +5,9 @@ require_once __DIR__.'/../Base.php';
use Kanboard\Core\Translator;
use Kanboard\Subscriber\ProjectModificationDateSubscriber;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
use Kanboard\Model\User;
use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
-use Kanboard\Model\Acl;
-use Kanboard\Model\Board;
use Kanboard\Model\Config;
use Kanboard\Model\Category;
@@ -44,6 +41,14 @@ class ProjectTest extends Base
$this->assertEmpty($project['token']);
}
+ public function testCreationWithDuplicateName()
+ {
+ $p = new Project($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
+ $this->assertEquals(2, $p->create(array('name' => 'UnitTest')));
+ }
+
public function testCreationWithStartAndDate()
{
$p = new Project($this->container);
@@ -79,6 +84,7 @@ class ProjectTest extends Base
// Single category
$this->assertTrue($c->save(array('project_categories' => 'Test1')));
+ $this->container['memoryCache']->flush();
$this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
$project = $p->getById(2);
@@ -92,6 +98,7 @@ class ProjectTest extends Base
// Multiple categories badly formatted
$this->assertTrue($c->save(array('project_categories' => 'ABC, , DEF 3, ')));
+ $this->container['memoryCache']->flush();
$this->assertEquals(3, $p->create(array('name' => 'UnitTest3')));
$project = $p->getById(3);
@@ -105,6 +112,7 @@ class ProjectTest extends Base
// No default categories
$this->assertTrue($c->save(array('project_categories' => ' ')));
+ $this->container['memoryCache']->flush();
$this->assertEquals(4, $p->create(array('name' => 'UnitTest4')));
$project = $p->getById(4);
@@ -270,30 +278,49 @@ class ProjectTest extends Base
$project = $p->getByIdentifier('');
$this->assertFalse($project);
+ }
- // Validation rules
- $r = $p->validateCreation(array('name' => 'test', 'identifier' => 'TEST1'));
- $this->assertFalse($r[0]);
+ public function testThatProjectCreatorAreAlsoOwner()
+ {
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
- $r = $p->validateCreation(array('name' => 'test', 'identifier' => 'test1'));
- $this->assertFalse($r[0]);
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'Me')));
+ $this->assertEquals(1, $projectModel->create(array('name' => 'My project 1'), 2));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'My project 2')));
- $r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'TEST1'));
- $this->assertTrue($r[0]);
+ $project = $projectModel->getByIdWithOwner(1);
+ $this->assertNotEmpty($project);
+ $this->assertSame('My project 1', $project['name']);
+ $this->assertSame('Me', $project['owner_name']);
+ $this->assertSame('user1', $project['owner_username']);
+ $this->assertEquals(2, $project['owner_id']);
- $r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'test3'));
- $this->assertTrue($r[0]);
+ $project = $projectModel->getByIdWithOwner(2);
+ $this->assertNotEmpty($project);
+ $this->assertSame('My project 2', $project['name']);
+ $this->assertEquals('', $project['owner_name']);
+ $this->assertEquals('', $project['owner_username']);
+ $this->assertEquals(0, $project['owner_id']);
+ }
- $r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => ''));
- $this->assertTrue($r[0]);
+ public function testPriority()
+ {
+ $projectModel = new Project($this->container);
+ $this->assertEquals(1, $projectModel->create(array('name' => 'My project 2')));
- $r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'TEST2'));
- $this->assertFalse($r[0]);
+ $project = $projectModel->getById(1);
+ $this->assertNotEmpty($project);
+ $this->assertEquals(0, $project['priority_default']);
+ $this->assertEquals(0, $project['priority_start']);
+ $this->assertEquals(3, $project['priority_end']);
- $r = $p->validateCreation(array('name' => 'test', 'identifier' => 'a-b-c'));
- $this->assertFalse($r[0]);
+ $this->assertTrue($projectModel->update(array('id' => 1, 'priority_start' => 2, 'priority_end' => 5, 'priority_default' => 4)));
- $r = $p->validateCreation(array('name' => 'test', 'identifier' => 'test 123'));
- $this->assertFalse($r[0]);
+ $project = $projectModel->getById(1);
+ $this->assertNotEmpty($project);
+ $this->assertEquals(4, $project['priority_default']);
+ $this->assertEquals(2, $project['priority_start']);
+ $this->assertEquals(5, $project['priority_end']);
}
}
diff --git a/tests/units/Model/ProjectUserRoleTest.php b/tests/units/Model/ProjectUserRoleTest.php
new file mode 100644
index 00000000..06cd1b70
--- /dev/null
+++ b/tests/units/Model/ProjectUserRoleTest.php
@@ -0,0 +1,461 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Project;
+use Kanboard\Model\User;
+use Kanboard\Model\Group;
+use Kanboard\Model\GroupMember;
+use Kanboard\Model\ProjectGroupRole;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\ProjectPermission;
+use Kanboard\Core\Security\Role;
+
+class ProjectUserRoleTest extends Base
+{
+ public function testAddUser()
+ {
+ $projectModel = new Project($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_VIEWER));
+ $this->assertFalse($userRoleModel->addUser(1, 1, Role::PROJECT_VIEWER));
+
+ $users = $userRoleModel->getUsers(1);
+ $this->assertCount(1, $users);
+ $this->assertEquals(1, $users[0]['id']);
+ $this->assertEquals('admin', $users[0]['username']);
+ $this->assertEquals('', $users[0]['name']);
+ $this->assertEquals(Role::PROJECT_VIEWER, $users[0]['role']);
+ }
+
+ public function testRemoveUser()
+ {
+ $projectModel = new Project($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->removeUser(1, 1));
+ $this->assertFalse($userRoleModel->removeUser(1, 1));
+
+ $this->assertEmpty($userRoleModel->getUsers(1));
+ }
+
+ public function testChangeRole()
+ {
+ $projectModel = new Project($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($userRoleModel->changeUserRole(1, 1, Role::PROJECT_MANAGER));
+
+ $users = $userRoleModel->getUsers(1);
+ $this->assertCount(1, $users);
+ $this->assertEquals(1, $users[0]['id']);
+ $this->assertEquals('admin', $users[0]['username']);
+ $this->assertEquals('', $users[0]['name']);
+ $this->assertEquals(Role::PROJECT_MANAGER, $users[0]['role']);
+ }
+
+ public function testGetRole()
+ {
+ $projectModel = new Project($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEmpty($userRoleModel->getUserRole(1, 1));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_VIEWER));
+ $this->assertEquals(Role::PROJECT_VIEWER, $userRoleModel->getUserRole(1, 1));
+
+ $this->assertTrue($userRoleModel->changeUserRole(1, 1, Role::PROJECT_MEMBER));
+ $this->assertEquals(Role::PROJECT_MEMBER, $userRoleModel->getUserRole(1, 1));
+
+ $this->assertTrue($userRoleModel->changeUserRole(1, 1, Role::PROJECT_MANAGER));
+ $this->assertEquals(Role::PROJECT_MANAGER, $userRoleModel->getUserRole(1, 1));
+
+ $this->assertEquals('', $userRoleModel->getUserRole(1, 2));
+ }
+
+ public function testGetRoleWithGroups()
+ {
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 1));
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+
+ $this->assertEquals(Role::PROJECT_VIEWER, $userRoleModel->getUserRole(1, 1));
+ $this->assertEquals('', $userRoleModel->getUserRole(1, 2));
+ }
+
+ public function testGetAssignableUsersWithDisabledUsers()
+ {
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User2')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(3, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+ $this->assertEquals('User2', $users[3]);
+
+ $this->assertTrue($userModel->disable(2));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(2, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User2', $users[3]);
+ }
+
+ public function testGetAssignableUsersWithoutGroups()
+ {
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User2')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_VIEWER));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(2, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+ }
+
+ public function testGetAssignableUsersWithGroups()
+ {
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
+ $groupModel = new Group($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3', 'name' => 'User3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user4', 'name' => 'User4')));
+
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_VIEWER));
+
+ $this->assertEquals(1, $groupModel->create('Group A'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 2, Role::PROJECT_MEMBER));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(3, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+ $this->assertEquals('User4', $users[5]);
+ }
+
+ public function testGetAssignableUsersList()
+ {
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Test2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User2')));
+
+ $this->assertTrue($userRoleModel->addUser(2, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(1, 2, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_VIEWER));
+
+ $users = $userRoleModel->getAssignableUsersList(1);
+ $this->assertCount(3, $users);
+
+ $this->assertEquals('Unassigned', $users[0]);
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+
+ $users = $userRoleModel->getAssignableUsersList(1, true, true, true);
+ $this->assertCount(4, $users);
+
+ $this->assertEquals('Unassigned', $users[0]);
+ $this->assertEquals('Everybody', $users[-1]);
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+
+ $users = $userRoleModel->getAssignableUsersList(2, true, true, true);
+ $this->assertCount(1, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ }
+
+ public function testGetAssignableUsersWithEverybodyAllowed()
+ {
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test', 'is_everybody_allowed' => 1)));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3', 'name' => 'User3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user4', 'name' => 'User4')));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(5, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+ $this->assertEquals('User2', $users[3]);
+ $this->assertEquals('User3', $users[4]);
+ $this->assertEquals('User4', $users[5]);
+ }
+
+ public function testGetAssignableUsersWithDisabledUsersAndEverybodyAllowed()
+ {
+ $projectModel = new Project($this->container);
+ $projectPermission = new ProjectPermission($this->container);
+ $userModel = new User($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User2')));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1', 'is_everybody_allowed' => 1)));
+
+ $this->assertTrue($projectPermission->isEverybodyAllowed(1));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(3, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User1', $users[2]);
+ $this->assertEquals('User2', $users[3]);
+
+ $this->assertTrue($userModel->disable(2));
+
+ $users = $userRoleModel->getAssignableUsers(1);
+ $this->assertCount(2, $users);
+
+ $this->assertEquals('admin', $users[1]);
+ $this->assertEquals('User2', $users[3]);
+ }
+
+ public function testGetProjectsByUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+ $this->assertEquals(6, $userModel->create(array('username' => 'user 5', 'name' => 'User #5')));
+ $this->assertEquals(7, $userModel->create(array('username' => 'user 6')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 6, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(2, 6, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(2, 7, Role::PROJECT_MEMBER));
+
+ $projects = $userRoleModel->getProjectsByUser(2);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(3);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(4);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(5);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+
+ $projects = $userRoleModel->getProjectsByUser(6);
+ $this->assertCount(2, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+ $this->assertEquals('Project 2', $projects[2]);
+
+ $projects = $userRoleModel->getProjectsByUser(7);
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+ }
+
+ public function testGetActiveProjectsByUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1', 'is_active' => 0)));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+ $this->assertEquals(6, $userModel->create(array('username' => 'user 5', 'name' => 'User #5')));
+ $this->assertEquals(7, $userModel->create(array('username' => 'user 6')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 6, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(2, 6, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(2, 7, Role::PROJECT_MEMBER));
+
+ $projects = $userRoleModel->getProjectsByUser(2, array(Project::ACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $userRoleModel->getProjectsByUser(3, array(Project::ACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $userRoleModel->getProjectsByUser(4, array(Project::ACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $userRoleModel->getProjectsByUser(5, array(Project::ACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+
+ $projects = $userRoleModel->getProjectsByUser(6, array(Project::ACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+
+ $projects = $userRoleModel->getProjectsByUser(7, array(Project::ACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 2', $projects[2]);
+ }
+
+ public function testGetInactiveProjectsByUser()
+ {
+ $userModel = new User($this->container);
+ $projectModel = new Project($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $groupRoleModel = new ProjectGroupRole($this->container);
+ $userRoleModel = new ProjectUserRole($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Project 1', 'is_active' => 0)));
+ $this->assertEquals(2, $projectModel->create(array('name' => 'Project 2')));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user 1', 'name' => 'User #1')));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user 2')));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user 3')));
+ $this->assertEquals(5, $userModel->create(array('username' => 'user 4')));
+ $this->assertEquals(6, $userModel->create(array('username' => 'user 5', 'name' => 'User #5')));
+ $this->assertEquals(7, $userModel->create(array('username' => 'user 6')));
+
+ $this->assertEquals(1, $groupModel->create('Group C'));
+ $this->assertEquals(2, $groupModel->create('Group B'));
+ $this->assertEquals(3, $groupModel->create('Group A'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 5));
+ $this->assertTrue($groupMemberModel->addUser(3, 3));
+ $this->assertTrue($groupMemberModel->addUser(3, 2));
+
+ $this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_VIEWER));
+ $this->assertTrue($groupRoleModel->addGroup(2, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($groupRoleModel->addGroup(1, 3, Role::PROJECT_MANAGER));
+
+ $this->assertTrue($userRoleModel->addUser(1, 6, Role::PROJECT_MANAGER));
+ $this->assertTrue($userRoleModel->addUser(2, 6, Role::PROJECT_MEMBER));
+ $this->assertTrue($userRoleModel->addUser(2, 7, Role::PROJECT_MEMBER));
+
+ $projects = $userRoleModel->getProjectsByUser(2, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(3, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(4, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(5, array(Project::INACTIVE));
+ $this->assertCount(0, $projects);
+
+ $projects = $userRoleModel->getProjectsByUser(6, array(Project::INACTIVE));
+ $this->assertCount(1, $projects);
+ $this->assertEquals('Project 1', $projects[1]);
+
+ $projects = $userRoleModel->getProjectsByUser(7, array(Project::INACTIVE));
+ $this->assertCount(0, $projects);
+ }
+}
diff --git a/tests/units/Model/SubtaskTest.php b/tests/units/Model/SubtaskTest.php
index 04b274cc..78945fd1 100644
--- a/tests/units/Model/SubtaskTest.php
+++ b/tests/units/Model/SubtaskTest.php
@@ -8,8 +8,7 @@ use Kanboard\Model\Subtask;
use Kanboard\Model\Project;
use Kanboard\Model\Category;
use Kanboard\Model\User;
-use Kanboard\Core\Session;
-use Kanboard\Model\UserSession;
+use Kanboard\Core\User\UserSession;
class SubtaskTest extends Base
{
@@ -160,7 +159,7 @@ class SubtaskTest extends Base
$this->assertEquals(0, $subtask['user_id']);
$this->assertEquals(1, $subtask['task_id']);
- $this->assertTrue($s->toggleStatus(1));
+ $this->assertEquals(Subtask::STATUS_INPROGRESS, $s->toggleStatus(1));
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
@@ -168,7 +167,7 @@ class SubtaskTest extends Base
$this->assertEquals(0, $subtask['user_id']);
$this->assertEquals(1, $subtask['task_id']);
- $this->assertTrue($s->toggleStatus(1));
+ $this->assertEquals(Subtask::STATUS_DONE, $s->toggleStatus(1));
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
@@ -176,7 +175,7 @@ class SubtaskTest extends Base
$this->assertEquals(0, $subtask['user_id']);
$this->assertEquals(1, $subtask['task_id']);
- $this->assertTrue($s->toggleStatus(1));
+ $this->assertEquals(Subtask::STATUS_TODO, $s->toggleStatus(1));
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
@@ -190,7 +189,6 @@ class SubtaskTest extends Base
$tc = new TaskCreation($this->container);
$s = new Subtask($this->container);
$p = new Project($this->container);
- $ss = new Session;
$us = new UserSession($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test1')));
@@ -205,9 +203,9 @@ class SubtaskTest extends Base
$this->assertEquals(1, $subtask['task_id']);
// Set the current logged user
- $ss['user'] = array('id' => 1);
+ $this->container['sessionStorage']->user = array('id' => 1);
- $this->assertTrue($s->toggleStatus(1));
+ $this->assertEquals(Subtask::STATUS_INPROGRESS, $s->toggleStatus(1));
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
@@ -215,7 +213,7 @@ class SubtaskTest extends Base
$this->assertEquals(1, $subtask['user_id']);
$this->assertEquals(1, $subtask['task_id']);
- $this->assertTrue($s->toggleStatus(1));
+ $this->assertEquals(Subtask::STATUS_DONE, $s->toggleStatus(1));
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
@@ -223,7 +221,7 @@ class SubtaskTest extends Base
$this->assertEquals(1, $subtask['user_id']);
$this->assertEquals(1, $subtask['task_id']);
- $this->assertTrue($s->toggleStatus(1));
+ $this->assertEquals(Subtask::STATUS_TODO, $s->toggleStatus(1));
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
@@ -254,117 +252,6 @@ class SubtaskTest extends Base
}
}
- public function testMoveUp()
- {
- $tc = new TaskCreation($this->container);
- $s = new Subtask($this->container);
- $p = new Project($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test1')));
- $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1)));
-
- $this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1)));
- $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1)));
- $this->assertEquals(3, $s->create(array('title' => 'subtask #3', 'task_id' => 1)));
-
- // Check positions
- $subtask = $s->getById(1);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(1, $subtask['position']);
-
- $subtask = $s->getById(2);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(2, $subtask['position']);
-
- $subtask = $s->getById(3);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(3, $subtask['position']);
-
- // Move up
- $this->assertTrue($s->moveUp(1, 2));
-
- // Check positions
- $subtask = $s->getById(1);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(2, $subtask['position']);
-
- $subtask = $s->getById(2);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(1, $subtask['position']);
-
- $subtask = $s->getById(3);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(3, $subtask['position']);
-
- // We can't move up #2
- $this->assertFalse($s->moveUp(1, 2));
-
- // Test remove
- $this->assertTrue($s->remove(1));
- $this->assertTrue($s->moveUp(1, 3));
-
- // Check positions
- $subtask = $s->getById(1);
- $this->assertEmpty($subtask);
-
- $subtask = $s->getById(2);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(2, $subtask['position']);
-
- $subtask = $s->getById(3);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(1, $subtask['position']);
- }
-
- public function testMoveDown()
- {
- $tc = new TaskCreation($this->container);
- $s = new Subtask($this->container);
- $p = new Project($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'test1')));
- $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1)));
-
- $this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1)));
- $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1)));
- $this->assertEquals(3, $s->create(array('title' => 'subtask #3', 'task_id' => 1)));
-
- // Move down #1
- $this->assertTrue($s->moveDown(1, 1));
-
- // Check positions
- $subtask = $s->getById(1);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(2, $subtask['position']);
-
- $subtask = $s->getById(2);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(1, $subtask['position']);
-
- $subtask = $s->getById(3);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(3, $subtask['position']);
-
- // We can't move down #3
- $this->assertFalse($s->moveDown(1, 3));
-
- // Test remove
- $this->assertTrue($s->remove(1));
- $this->assertTrue($s->moveDown(1, 2));
-
- // Check positions
- $subtask = $s->getById(1);
- $this->assertEmpty($subtask);
-
- $subtask = $s->getById(2);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(2, $subtask['position']);
-
- $subtask = $s->getById(3);
- $this->assertNotEmpty($subtask);
- $this->assertEquals(1, $subtask['position']);
- }
-
public function testDuplicate()
{
$tc = new TaskCreation($this->container);
@@ -411,4 +298,69 @@ class SubtaskTest extends Base
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(2, $subtasks[1]['position']);
}
+
+ public function testChangePosition()
+ {
+ $taskCreationModel = new TaskCreation($this->container);
+ $subtaskModel = new Subtask($this->container);
+ $projectModel = new Project($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1)));
+
+ $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1)));
+ $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1)));
+ $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 1)));
+
+ $subtasks = $subtaskModel->getAll(1);
+ $this->assertEquals(1, $subtasks[0]['position']);
+ $this->assertEquals(1, $subtasks[0]['id']);
+ $this->assertEquals(2, $subtasks[1]['position']);
+ $this->assertEquals(2, $subtasks[1]['id']);
+ $this->assertEquals(3, $subtasks[2]['position']);
+ $this->assertEquals(3, $subtasks[2]['id']);
+
+ $this->assertTrue($subtaskModel->changePosition(1, 3, 2));
+
+ $subtasks = $subtaskModel->getAll(1);
+ $this->assertEquals(1, $subtasks[0]['position']);
+ $this->assertEquals(1, $subtasks[0]['id']);
+ $this->assertEquals(2, $subtasks[1]['position']);
+ $this->assertEquals(3, $subtasks[1]['id']);
+ $this->assertEquals(3, $subtasks[2]['position']);
+ $this->assertEquals(2, $subtasks[2]['id']);
+
+ $this->assertTrue($subtaskModel->changePosition(1, 2, 1));
+
+ $subtasks = $subtaskModel->getAll(1);
+ $this->assertEquals(1, $subtasks[0]['position']);
+ $this->assertEquals(2, $subtasks[0]['id']);
+ $this->assertEquals(2, $subtasks[1]['position']);
+ $this->assertEquals(1, $subtasks[1]['id']);
+ $this->assertEquals(3, $subtasks[2]['position']);
+ $this->assertEquals(3, $subtasks[2]['id']);
+
+ $this->assertTrue($subtaskModel->changePosition(1, 2, 2));
+
+ $subtasks = $subtaskModel->getAll(1);
+ $this->assertEquals(1, $subtasks[0]['position']);
+ $this->assertEquals(1, $subtasks[0]['id']);
+ $this->assertEquals(2, $subtasks[1]['position']);
+ $this->assertEquals(2, $subtasks[1]['id']);
+ $this->assertEquals(3, $subtasks[2]['position']);
+ $this->assertEquals(3, $subtasks[2]['id']);
+
+ $this->assertTrue($subtaskModel->changePosition(1, 1, 3));
+
+ $subtasks = $subtaskModel->getAll(1);
+ $this->assertEquals(1, $subtasks[0]['position']);
+ $this->assertEquals(2, $subtasks[0]['id']);
+ $this->assertEquals(2, $subtasks[1]['position']);
+ $this->assertEquals(3, $subtasks[1]['id']);
+ $this->assertEquals(3, $subtasks[2]['position']);
+ $this->assertEquals(1, $subtasks[2]['id']);
+
+ $this->assertFalse($subtaskModel->changePosition(1, 2, 0));
+ $this->assertFalse($subtaskModel->changePosition(1, 2, 4));
+ }
}
diff --git a/tests/units/Model/SubtaskTimeTrackingTest.php b/tests/units/Model/SubtaskTimeTrackingTest.php
index 309be64a..40461eea 100644
--- a/tests/units/Model/SubtaskTimeTrackingTest.php
+++ b/tests/units/Model/SubtaskTimeTrackingTest.php
@@ -9,7 +9,6 @@ use Kanboard\Model\SubtaskTimeTracking;
use Kanboard\Model\Project;
use Kanboard\Model\Category;
use Kanboard\Model\User;
-use Kanboard\Core\Session;
class SubtaskTimeTrackingTest extends Base
{
@@ -38,9 +37,8 @@ class SubtaskTimeTrackingTest extends Base
$s = new Subtask($this->container);
$st = new SubtaskTimeTracking($this->container);
$p = new Project($this->container);
- $ss = new Session;
- $ss['user'] = array('id' => 1);
+ $this->container['sessionStorage']->user = array('id' => 1);
$this->assertEquals(1, $p->create(array('name' => 'test1')));
$this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1)));
diff --git a/tests/units/Model/SwimlaneTest.php b/tests/units/Model/SwimlaneTest.php
index 3d048abd..f8b496cf 100644
--- a/tests/units/Model/SwimlaneTest.php
+++ b/tests/units/Model/SwimlaneTest.php
@@ -86,7 +86,23 @@ class SwimlaneTest extends Base
$this->assertEquals(0, $default['show_default_swimlane']);
}
- public function testDisable()
+ public function testDisableEnableDefaultSwimlane()
+ {
+ $projectModel = new Project($this->container);
+ $swimlaneModel = new Swimlane($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'UnitTest')));
+
+ $this->assertTrue($swimlaneModel->disableDefault(1));
+ $default = $swimlaneModel->getDefault(1);
+ $this->assertEquals(0, $default['show_default_swimlane']);
+
+ $this->assertTrue($swimlaneModel->enableDefault(1));
+ $default = $swimlaneModel->getDefault(1);
+ $this->assertEquals(1, $default['show_default_swimlane']);
+ }
+
+ public function testDisableEnable()
{
$p = new Project($this->container);
$s = new Swimlane($this->container);
@@ -210,172 +226,6 @@ class SwimlaneTest extends Base
$this->assertEquals(1, $swimlane['position']);
}
- public function testMoveUp()
- {
- $p = new Project($this->container);
- $s = new Swimlane($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'Swimlane #1')));
- $this->assertEquals(2, $s->create(array('project_id' => 1, 'name' => 'Swimlane #2')));
- $this->assertEquals(3, $s->create(array('project_id' => 1, 'name' => 'Swimlane #3')));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(3, $swimlane['position']);
-
- // Move the swimlane 3 up
- $this->assertTrue($s->moveUp(1, 3));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(3, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- // First swimlane can be moved up
- $this->assertFalse($s->moveUp(1, 1));
-
- // Move with a disabled swimlane
- $this->assertTrue($s->disable(1, 1));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(0, $swimlane['is_active']);
- $this->assertEquals(0, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- // Move the 2nd swimlane up
- $this->assertTrue($s->moveUp(1, 2));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(0, $swimlane['is_active']);
- $this->assertEquals(0, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
- }
-
- public function testMoveDown()
- {
- $p = new Project($this->container);
- $s = new Swimlane($this->container);
-
- $this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
- $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'Swimlane #1')));
- $this->assertEquals(2, $s->create(array('project_id' => 1, 'name' => 'Swimlane #2')));
- $this->assertEquals(3, $s->create(array('project_id' => 1, 'name' => 'Swimlane #3')));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(3, $swimlane['position']);
-
- // Move the swimlane 1 down
- $this->assertTrue($s->moveDown(1, 1));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(3, $swimlane['position']);
-
- // Last swimlane can be moved down
- $this->assertFalse($s->moveDown(1, 3));
-
- // Move with a disabled swimlane
- $this->assertTrue($s->disable(1, 3));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(0, $swimlane['is_active']);
- $this->assertEquals(0, $swimlane['position']);
-
- // Move the 2st swimlane down
- $this->assertTrue($s->moveDown(1, 2));
-
- $swimlane = $s->getById(1);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(1, $swimlane['position']);
-
- $swimlane = $s->getById(2);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(1, $swimlane['is_active']);
- $this->assertEquals(2, $swimlane['position']);
-
- $swimlane = $s->getById(3);
- $this->assertNotEmpty($swimlane);
- $this->assertEquals(0, $swimlane['is_active']);
- $this->assertEquals(0, $swimlane['position']);
- }
-
public function testDuplicateSwimlane()
{
$p = new Project($this->container);
@@ -406,4 +256,93 @@ class SwimlaneTest extends Base
$new_default = $s->getDefault(2);
$this->assertEquals('New Default', $new_default['default_swimlane']);
}
+
+ public function testChangePosition()
+ {
+ $projectModel = new Project($this->container);
+ $swimlaneModel = new Swimlane($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #1')));
+ $this->assertEquals(2, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #2')));
+ $this->assertEquals(3, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #3')));
+ $this->assertEquals(4, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #4')));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(1, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(2, $swimlanes[1]['id']);
+ $this->assertEquals(3, $swimlanes[2]['position']);
+ $this->assertEquals(3, $swimlanes[2]['id']);
+
+ $this->assertTrue($swimlaneModel->changePosition(1, 3, 2));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(1, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(3, $swimlanes[1]['id']);
+ $this->assertEquals(3, $swimlanes[2]['position']);
+ $this->assertEquals(2, $swimlanes[2]['id']);
+
+ $this->assertTrue($swimlaneModel->changePosition(1, 2, 1));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(2, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(1, $swimlanes[1]['id']);
+ $this->assertEquals(3, $swimlanes[2]['position']);
+ $this->assertEquals(3, $swimlanes[2]['id']);
+
+ $this->assertTrue($swimlaneModel->changePosition(1, 2, 2));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(1, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(2, $swimlanes[1]['id']);
+ $this->assertEquals(3, $swimlanes[2]['position']);
+ $this->assertEquals(3, $swimlanes[2]['id']);
+
+ $this->assertTrue($swimlaneModel->changePosition(1, 4, 1));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(4, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(1, $swimlanes[1]['id']);
+ $this->assertEquals(3, $swimlanes[2]['position']);
+ $this->assertEquals(2, $swimlanes[2]['id']);
+
+ $this->assertFalse($swimlaneModel->changePosition(1, 2, 0));
+ $this->assertFalse($swimlaneModel->changePosition(1, 2, 5));
+ }
+
+ public function testChangePositionWithInactiveSwimlane()
+ {
+ $projectModel = new Project($this->container);
+ $swimlaneModel = new Swimlane($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
+ $this->assertEquals(1, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #1')));
+ $this->assertEquals(2, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #2', 'is_active' => 0)));
+ $this->assertEquals(3, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #3', 'is_active' => 0)));
+ $this->assertEquals(4, $swimlaneModel->create(array('project_id' => 1, 'name' => 'Swimlane #4')));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(1, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(4, $swimlanes[1]['id']);
+
+ $this->assertTrue($swimlaneModel->changePosition(1, 4, 1));
+
+ $swimlanes = $swimlaneModel->getAllByStatus(1);
+ $this->assertEquals(1, $swimlanes[0]['position']);
+ $this->assertEquals(4, $swimlanes[0]['id']);
+ $this->assertEquals(2, $swimlanes[1]['position']);
+ $this->assertEquals(1, $swimlanes[1]['id']);
+ }
}
diff --git a/tests/units/Model/TaskCreationTest.php b/tests/units/Model/TaskCreationTest.php
index d76937b2..781a7147 100644
--- a/tests/units/Model/TaskCreationTest.php
+++ b/tests/units/Model/TaskCreationTest.php
@@ -8,7 +8,6 @@ use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\TaskStatus;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
class TaskCreationTest extends Base
{
@@ -182,8 +181,7 @@ class TaskCreationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
- $_SESSION = array();
- $_SESSION['user']['id'] = 1;
+ $this->container['sessionStorage']->user = array('id' => 1);
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test')));
@@ -194,8 +192,6 @@ class TaskCreationTest extends Base
$this->assertEquals(1, $task['id']);
$this->assertEquals(1, $task['creator_id']);
-
- $_SESSION = array();
}
public function testColumnId()
@@ -299,7 +295,7 @@ class TaskCreationTest extends Base
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertEquals(2, $task['id']);
- $this->assertEquals($timestamp, $task['date_due']);
+ $this->assertEquals(date('Y-m-d 00:00', $timestamp), date('Y-m-d 00:00', $task['date_due']));
$task = $tf->getById(3);
$this->assertEquals(3, $task['id']);
@@ -406,6 +402,7 @@ class TaskCreationTest extends Base
$this->assertEquals('yellow', $task['color_id']);
$this->assertTrue($c->save(array('default_color' => 'orange')));
+ $this->container['memoryCache']->flush();
$this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'test2')));
@@ -425,6 +422,6 @@ class TaskCreationTest extends Base
$task = $tf->getById(1);
$this->assertNotEmpty($task);
- $this->assertEquals('2050-01-10 12:30', date('Y-m-d H:i', $task['date_due']));
+ $this->assertEquals('2050-01-10 00:00', date('Y-m-d H:i', $task['date_due']));
}
}
diff --git a/tests/units/Model/TaskDuplicationTest.php b/tests/units/Model/TaskDuplicationTest.php
index 5273928c..8649c6b0 100644
--- a/tests/units/Model/TaskDuplicationTest.php
+++ b/tests/units/Model/TaskDuplicationTest.php
@@ -2,16 +2,18 @@
require_once __DIR__.'/../Base.php';
+use Kanboard\Core\DateParser;
use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskDuplication;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\TaskStatus;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
+use Kanboard\Model\ProjectUserRole;
use Kanboard\Model\Category;
use Kanboard\Model\User;
use Kanboard\Model\Swimlane;
+use Kanboard\Core\Security\Role;
class TaskDuplicationTest extends Base
{
@@ -31,8 +33,7 @@ class TaskDuplicationTest extends Base
$this->assertEquals(1, $task['project_id']);
$this->assertEquals(0, $task['creator_id']);
- $_SESSION = array();
- $_SESSION['user']['id'] = 1;
+ $this->container['sessionStorage']->user = array('id' => 1);
// We duplicate our task
$this->assertEquals(2, $td->duplicate(1));
@@ -41,8 +42,6 @@ class TaskDuplicationTest extends Base
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertEquals(1, $task['creator_id']);
-
- $_SESSION = array();
}
public function testDuplicateSameProject()
@@ -130,7 +129,7 @@ class TaskDuplicationTest extends Base
// Check the values of the duplicated task
$task = $tf->getById(2);
$this->assertNotEmpty($task);
- $this->assertEquals(1, $task['owner_id']);
+ $this->assertEquals(0, $task['owner_id']);
$this->assertEquals(0, $task['category_id']);
$this->assertEquals(0, $task['swimlane_id']);
$this->assertEquals(6, $task['column_id']);
@@ -336,7 +335,7 @@ class TaskDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
+ $pp = new ProjectUserRole($this->container);
// We create 2 projects
$this->assertEquals(1, $p->create(array('name' => 'test1')));
@@ -360,10 +359,8 @@ class TaskDuplicationTest extends Base
// We create a new user for our project
$user = new User($this->container);
$this->assertNotFalse($user->create(array('username' => 'unittest#1', 'password' => 'unittest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->addMember(2, 2));
- $this->assertTrue($pp->isUserAllowed(1, 2));
- $this->assertTrue($pp->isUserAllowed(2, 2));
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(2, 2, Role::PROJECT_MEMBER));
// We duplicate our task to the 2nd project
$this->assertEquals(3, $td->duplicateToProject(1, 2));
@@ -394,7 +391,7 @@ class TaskDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
+ $pr = new ProjectUserRole($this->container);
// We create 2 projects
$this->assertEquals(1, $p->create(array('name' => 'test1')));
@@ -402,6 +399,7 @@ class TaskDuplicationTest extends Base
// We create a task
$this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 2)));
+ $this->assertTrue($pr->addUser(2, 1, Role::PROJECT_MEMBER));
// We duplicate our task to the 2nd project
$this->assertEquals(2, $td->duplicateToProject(1, 2, null, null, null, 1));
@@ -428,7 +426,6 @@ class TaskDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
$user = new User($this->container);
// We create 2 projects
@@ -449,7 +446,7 @@ class TaskDuplicationTest extends Base
// Check the values of the moved task
$task = $tf->getById(1);
$this->assertNotEmpty($task);
- $this->assertEquals(1, $task['owner_id']);
+ $this->assertEquals(0, $task['owner_id']);
$this->assertEquals(0, $task['category_id']);
$this->assertEquals(0, $task['swimlane_id']);
$this->assertEquals(2, $task['project_id']);
@@ -499,7 +496,7 @@ class TaskDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
+ $pp = new ProjectUserRole($this->container);
$user = new User($this->container);
// We create 2 projects
@@ -508,10 +505,8 @@ class TaskDuplicationTest extends Base
// We create a new user for our project
$this->assertNotFalse($user->create(array('username' => 'unittest#1', 'password' => 'unittest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->addMember(2, 2));
- $this->assertTrue($pp->isUserAllowed(1, 2));
- $this->assertTrue($pp->isUserAllowed(2, 2));
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(2, 2, Role::PROJECT_MEMBER));
// We create a task
$this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 2)));
@@ -534,7 +529,7 @@ class TaskDuplicationTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
- $pp = new ProjectPermission($this->container);
+ $pp = new ProjectUserRole($this->container);
$user = new User($this->container);
// We create 2 projects
@@ -543,10 +538,8 @@ class TaskDuplicationTest extends Base
// We create a new user for our project
$this->assertNotFalse($user->create(array('username' => 'unittest#1', 'password' => 'unittest')));
- $this->assertTrue($pp->addMember(1, 2));
- $this->assertTrue($pp->addMember(2, 2));
- $this->assertTrue($pp->isUserAllowed(1, 2));
- $this->assertTrue($pp->isUserAllowed(2, 2));
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(2, 2, Role::PROJECT_MEMBER));
// We create a task
$this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 3)));
@@ -673,6 +666,7 @@ class TaskDuplicationTest extends Base
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
$c = new Category($this->container);
+ $dp = new DateParser($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test1')));
@@ -693,7 +687,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'], '', 2);
+ $this->assertEquals(1436486400, $task['date_due'], '', 2);
$task = $tf->getById(2);
$this->assertNotEmpty($task);
@@ -703,6 +697,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'], '', 2);
+ $this->assertEquals($dp->removeTimeFromTimestamp(strtotime('+2 days')), $task['date_due'], '', 2);
}
}
diff --git a/tests/units/Model/TaskExportTest.php b/tests/units/Model/TaskExportTest.php
index 40b3a5a2..b40b0771 100644
--- a/tests/units/Model/TaskExportTest.php
+++ b/tests/units/Model/TaskExportTest.php
@@ -51,7 +51,7 @@ class TaskExportTest extends Base
$this->assertEquals($i, count($rows));
$this->assertEquals('Task Id', $rows[0][0]);
$this->assertEquals(1, $rows[1][0]);
- $this->assertEquals('Task #'.($i - 1), $rows[$i - 1][12]);
+ $this->assertEquals('Task #'.($i - 1), $rows[$i - 1][13]);
$this->assertTrue(in_array($rows[$i - 1][4], array('Default swimlane', 'S1', 'S2')));
}
}
diff --git a/tests/units/Model/TaskExternalLinkTest.php b/tests/units/Model/TaskExternalLinkTest.php
new file mode 100644
index 00000000..28ccab83
--- /dev/null
+++ b/tests/units/Model/TaskExternalLinkTest.php
@@ -0,0 +1,123 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Model\TaskExternalLink;
+use Kanboard\Core\ExternalLink\ExternalLinkManager;
+use Kanboard\ExternalLink\WebLinkProvider;
+
+class TaskExternalLinkTest extends Base
+{
+ public function testCreate()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskExternalLinkModel = new TaskExternalLink($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Test', 'project_id' => 1)));
+ $this->assertEquals(1, $taskExternalLinkModel->create(array('task_id' => 1, 'id' => '', 'url' => 'http://kanboard.net/', 'title' => 'My website', 'link_type' => 'weblink', 'dependency' => 'related')));
+
+ $link = $taskExternalLinkModel->getById(1);
+ $this->assertNotEmpty($link);
+ $this->assertEquals('My website', $link['title']);
+ $this->assertEquals('http://kanboard.net/', $link['url']);
+ $this->assertEquals('related', $link['dependency']);
+ $this->assertEquals('weblink', $link['link_type']);
+ $this->assertEquals(0, $link['creator_id']);
+ $this->assertEquals(time(), $link['date_modification'], '', 2);
+ $this->assertEquals(time(), $link['date_creation'], '', 2);
+ }
+
+ public function testCreateWithUserSession()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskExternalLinkModel = new TaskExternalLink($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Test', 'project_id' => 1)));
+ $this->assertEquals(1, $taskExternalLinkModel->create(array('task_id' => 1, 'id' => '', 'url' => 'http://kanboard.net/', 'title' => 'My website', 'link_type' => 'weblink', 'dependency' => 'related')));
+
+ $link = $taskExternalLinkModel->getById(1);
+ $this->assertNotEmpty($link);
+ $this->assertEquals('My website', $link['title']);
+ $this->assertEquals('http://kanboard.net/', $link['url']);
+ $this->assertEquals('related', $link['dependency']);
+ $this->assertEquals('weblink', $link['link_type']);
+ $this->assertEquals(1, $link['creator_id']);
+ $this->assertEquals(time(), $link['date_modification'], '', 2);
+ $this->assertEquals(time(), $link['date_creation'], '', 2);
+ }
+
+ public function testModification()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskExternalLinkModel = new TaskExternalLink($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Test', 'project_id' => 1)));
+ $this->assertEquals(1, $taskExternalLinkModel->create(array('task_id' => 1, 'id' => '', 'url' => 'http://kanboard.net/', 'title' => 'My website', 'link_type' => 'weblink', 'dependency' => 'related')));
+
+ sleep(1);
+
+ $this->assertTrue($taskExternalLinkModel->update(array('id' => 1, 'url' => 'https://kanboard.net/')));
+
+ $link = $taskExternalLinkModel->getById(1);
+ $this->assertNotEmpty($link);
+ $this->assertEquals('https://kanboard.net/', $link['url']);
+ $this->assertEquals(time(), $link['date_modification'], '', 2);
+ }
+
+ public function testRemove()
+ {
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskExternalLinkModel = new TaskExternalLink($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Test', 'project_id' => 1)));
+ $this->assertEquals(1, $taskExternalLinkModel->create(array('task_id' => 1, 'id' => '', 'url' => 'http://kanboard.net/', 'title' => 'My website', 'link_type' => 'weblink', 'dependency' => 'related')));
+
+ $this->assertTrue($taskExternalLinkModel->remove(1));
+ $this->assertFalse($taskExternalLinkModel->remove(1));
+
+ $this->assertEmpty($taskExternalLinkModel->getById(1));
+ }
+
+ public function testGetAll()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+ $this->container['externalLinkManager'] = new ExternalLinkManager($this->container);
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $taskExternalLinkModel = new TaskExternalLink($this->container);
+ $webLinkProvider = new WebLinkProvider($this->container);
+
+ $this->container['externalLinkManager']->register($webLinkProvider);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Test', 'project_id' => 1)));
+ $this->assertEquals(1, $taskExternalLinkModel->create(array('task_id' => 1, 'url' => 'https://miniflux.net/', 'title' => 'MX', 'link_type' => 'weblink', 'dependency' => 'related')));
+ $this->assertEquals(2, $taskExternalLinkModel->create(array('task_id' => 1, 'url' => 'http://kanboard.net/', 'title' => 'KB', 'link_type' => 'weblink', 'dependency' => 'related')));
+
+ $links = $taskExternalLinkModel->getAll(1);
+ $this->assertCount(2, $links);
+ $this->assertEquals('KB', $links[0]['title']);
+ $this->assertEquals('MX', $links[1]['title']);
+ $this->assertEquals('Web Link', $links[0]['type']);
+ $this->assertEquals('Web Link', $links[1]['type']);
+ $this->assertEquals('Related', $links[0]['dependency_label']);
+ $this->assertEquals('Related', $links[1]['dependency_label']);
+ $this->assertEquals('admin', $links[0]['creator_username']);
+ $this->assertEquals('admin', $links[1]['creator_username']);
+ $this->assertEquals('', $links[0]['creator_name']);
+ $this->assertEquals('', $links[1]['creator_name']);
+ }
+}
diff --git a/tests/units/Model/TaskFileTest.php b/tests/units/Model/TaskFileTest.php
new file mode 100644
index 00000000..b7db96a9
--- /dev/null
+++ b/tests/units/Model/TaskFileTest.php
@@ -0,0 +1,446 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskFile;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+
+class TaskFileTest extends Base
+{
+ public function testCreation()
+ {
+ $projectModel = new Project($this->container);
+ $fileModel = new TaskFile($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->assertEquals(1, $fileModel->create(1, 'test', '/tmp/foo', 10));
+
+ $file = $fileModel->getById(1);
+ $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, $fileModel->create(1, 'test2.png', '/tmp/foobar', 10));
+
+ $file = $fileModel->getById(2);
+ $this->assertEquals('test2.png', $file['name']);
+ $this->assertEquals('/tmp/foobar', $file['path']);
+ $this->assertEquals(1, $file['is_image']);
+ }
+
+ public function testCreationWithFileNameTooLong()
+ {
+ $projectModel = new Project($this->container);
+ $fileModel = new TaskFile($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->assertNotFalse($fileModel->create(1, 'test', '/tmp/foo', 10));
+ $this->assertNotFalse($fileModel->create(1, str_repeat('a', 1000), '/tmp/foo', 10));
+
+ $files = $fileModel->getAll(1);
+ $this->assertNotEmpty($files);
+ $this->assertCount(2, $files);
+
+ $this->assertEquals(str_repeat('a', 255), $files[0]['name']);
+ $this->assertEquals('test', $files[1]['name']);
+ }
+
+ public function testCreationWithSessionOpen()
+ {
+ $this->container['sessionStorage']->user = array('id' => 1);
+
+ $projectModel = new Project($this->container);
+ $fileModel = new TaskFile($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'test', '/tmp/foo', 10));
+
+ $file = $fileModel->getById(1);
+ $this->assertEquals('test', $file['name']);
+ $this->assertEquals(1, $file['user_id']);
+ }
+
+ public function testGetAll()
+ {
+ $projectModel = new Project($this->container);
+ $fileModel = new TaskFile($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->assertEquals(1, $fileModel->create(1, 'B.pdf', '/tmp/foo', 10));
+ $this->assertEquals(2, $fileModel->create(1, 'A.png', '/tmp/foo', 10));
+ $this->assertEquals(3, $fileModel->create(1, 'D.doc', '/tmp/foo', 10));
+ $this->assertEquals(4, $fileModel->create(1, 'C.JPG', '/tmp/foo', 10));
+
+ $fileModeliles = $fileModel->getAll(1);
+ $this->assertNotEmpty($fileModeliles);
+ $this->assertCount(4, $fileModeliles);
+ $this->assertEquals('A.png', $fileModeliles[0]['name']);
+ $this->assertEquals('B.pdf', $fileModeliles[1]['name']);
+ $this->assertEquals('C.JPG', $fileModeliles[2]['name']);
+ $this->assertEquals('D.doc', $fileModeliles[3]['name']);
+
+ $fileModeliles = $fileModel->getAllImages(1);
+ $this->assertNotEmpty($fileModeliles);
+ $this->assertCount(2, $fileModeliles);
+ $this->assertEquals('A.png', $fileModeliles[0]['name']);
+ $this->assertEquals('C.JPG', $fileModeliles[1]['name']);
+
+ $fileModeliles = $fileModel->getAllDocuments(1);
+ $this->assertNotEmpty($fileModeliles);
+ $this->assertCount(2, $fileModeliles);
+ $this->assertEquals('B.pdf', $fileModeliles[0]['name']);
+ $this->assertEquals('D.doc', $fileModeliles[1]['name']);
+ }
+
+ public function testIsImage()
+ {
+ $fileModel = new TaskFile($this->container);
+
+ $this->assertTrue($fileModel->isImage('test.png'));
+ $this->assertTrue($fileModel->isImage('test.jpeg'));
+ $this->assertTrue($fileModel->isImage('test.gif'));
+ $this->assertTrue($fileModel->isImage('test.jpg'));
+ $this->assertTrue($fileModel->isImage('test.JPG'));
+
+ $this->assertFalse($fileModel->isImage('test.bmp'));
+ $this->assertFalse($fileModel->isImage('test'));
+ $this->assertFalse($fileModel->isImage('test.pdf'));
+ }
+
+ public function testGetThumbnailPath()
+ {
+ $fileModel = new TaskFile($this->container);
+ $this->assertEquals('thumbnails'.DIRECTORY_SEPARATOR.'test', $fileModel->getThumbnailPath('test'));
+ }
+
+ public function testGeneratePath()
+ {
+ $fileModel = new TaskFile($this->container);
+
+ $this->assertStringStartsWith('tasks'.DIRECTORY_SEPARATOR.'34'.DIRECTORY_SEPARATOR, $fileModel->generatePath(34, 'test.png'));
+ $this->assertNotEquals($fileModel->generatePath(34, 'test1.png'), $fileModel->generatePath(34, 'test2.png'));
+ }
+
+ public function testUploadFiles()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\TaskFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $files = array(
+ 'name' => array(
+ 'file1.png',
+ 'file2.doc',
+ ),
+ 'tmp_name' => array(
+ '/tmp/phpYzdqkD',
+ '/tmp/phpeEwEWG',
+ ),
+ 'error' => array(
+ UPLOAD_ERR_OK,
+ UPLOAD_ERR_OK,
+ ),
+ 'size' => array(
+ 123,
+ 456,
+ ),
+ );
+
+ $fileModel
+ ->expects($this->once())
+ ->method('generateThumbnailFromFile');
+
+ $this->container['objectStorage']
+ ->expects($this->at(0))
+ ->method('moveUploadedFile')
+ ->with($this->equalTo('/tmp/phpYzdqkD'), $this->anything());
+
+ $this->container['objectStorage']
+ ->expects($this->at(1))
+ ->method('moveUploadedFile')
+ ->with($this->equalTo('/tmp/phpeEwEWG'), $this->anything());
+
+ $this->assertTrue($fileModel->uploadFiles(1, $files));
+
+ $files = $fileModel->getAll(1);
+ $this->assertCount(2, $files);
+
+ $this->assertEquals(1, $files[0]['id']);
+ $this->assertEquals('file1.png', $files[0]['name']);
+ $this->assertEquals(1, $files[0]['is_image']);
+ $this->assertEquals(1, $files[0]['task_id']);
+ $this->assertEquals(0, $files[0]['user_id']);
+ $this->assertEquals(123, $files[0]['size']);
+ $this->assertEquals(time(), $files[0]['date'], '', 2);
+
+ $this->assertEquals(2, $files[1]['id']);
+ $this->assertEquals('file2.doc', $files[1]['name']);
+ $this->assertEquals(0, $files[1]['is_image']);
+ $this->assertEquals(1, $files[1]['task_id']);
+ $this->assertEquals(0, $files[1]['user_id']);
+ $this->assertEquals(456, $files[1]['size']);
+ $this->assertEquals(time(), $files[1]['date'], '', 2);
+ }
+
+ public function testUploadFilesWithEmptyFiles()
+ {
+ $fileModel = new TaskFile($this->container);
+ $this->assertFalse($fileModel->uploadFiles(1, array()));
+ }
+
+ public function testUploadFilesWithUploadError()
+ {
+ $files = array(
+ 'name' => array(
+ 'file1.png',
+ 'file2.doc',
+ ),
+ 'tmp_name' => array(
+ '',
+ '/tmp/phpeEwEWG',
+ ),
+ 'error' => array(
+ UPLOAD_ERR_CANT_WRITE,
+ UPLOAD_ERR_OK,
+ ),
+ 'size' => array(
+ 123,
+ 456,
+ ),
+ );
+
+ $fileModel = new TaskFile($this->container);
+ $this->assertFalse($fileModel->uploadFiles(1, $files));
+ }
+
+ public function testUploadFilesWithObjectStorageError()
+ {
+ $files = array(
+ 'name' => array(
+ 'file1.csv',
+ 'file2.doc',
+ ),
+ 'tmp_name' => array(
+ '/tmp/phpYzdqkD',
+ '/tmp/phpeEwEWG',
+ ),
+ 'error' => array(
+ UPLOAD_ERR_OK,
+ UPLOAD_ERR_OK,
+ ),
+ 'size' => array(
+ 123,
+ 456,
+ ),
+ );
+
+ $this->container['objectStorage']
+ ->expects($this->at(0))
+ ->method('moveUploadedFile')
+ ->with($this->equalTo('/tmp/phpYzdqkD'), $this->anything())
+ ->will($this->throwException(new \Kanboard\Core\ObjectStorage\ObjectStorageException('test')));
+
+ $fileModel = new TaskFile($this->container);
+ $this->assertFalse($fileModel->uploadFiles(1, $files));
+ }
+
+ public function testUploadFileContent()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\TaskFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $data = 'test';
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('put')
+ ->with($this->anything(), $this->equalTo($data));
+
+ $this->assertEquals(1, $fileModel->uploadContent(1, 'test.doc', base64_encode($data)));
+
+ $files = $fileModel->getAll(1);
+ $this->assertCount(1, $files);
+
+ $this->assertEquals(1, $files[0]['id']);
+ $this->assertEquals('test.doc', $files[0]['name']);
+ $this->assertEquals(0, $files[0]['is_image']);
+ $this->assertEquals(1, $files[0]['task_id']);
+ $this->assertEquals(0, $files[0]['user_id']);
+ $this->assertEquals(4, $files[0]['size']);
+ $this->assertEquals(time(), $files[0]['date'], '', 2);
+ }
+
+ public function testUploadFileContentWithObjectStorageError()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\TaskFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $data = 'test';
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('put')
+ ->with($this->anything(), $this->equalTo($data))
+ ->will($this->throwException(new \Kanboard\Core\ObjectStorage\ObjectStorageException('test')));
+
+ $this->assertFalse($fileModel->uploadContent(1, 'test.doc', base64_encode($data)));
+ }
+
+ public function testUploadScreenshot()
+ {
+ $fileModel = $this
+ ->getMockBuilder('\Kanboard\Model\TaskFile')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('generateThumbnailFromFile'))
+ ->getMock();
+
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $data = 'test';
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $fileModel
+ ->expects($this->once())
+ ->method('generateThumbnailFromFile');
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('put')
+ ->with($this->anything(), $this->equalTo($data));
+
+ $this->assertEquals(1, $fileModel->uploadScreenshot(1, base64_encode($data)));
+
+ $files = $fileModel->getAll(1);
+ $this->assertCount(1, $files);
+
+ $this->assertEquals(1, $files[0]['id']);
+ $this->assertStringStartsWith('Screenshot taken ', $files[0]['name']);
+ $this->assertEquals(1, $files[0]['is_image']);
+ $this->assertEquals(1, $files[0]['task_id']);
+ $this->assertEquals(0, $files[0]['user_id']);
+ $this->assertEquals(4, $files[0]['size']);
+ $this->assertEquals(time(), $files[0]['date'], '', 2);
+ }
+
+ public function testRemove()
+ {
+ $fileModel = new TaskFile($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'test', 'tmp/foo', 10));
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('remove')
+ ->with('tmp/foo');
+
+ $this->assertTrue($fileModel->remove(1));
+ }
+
+ public function testRemoveWithObjectStorageError()
+ {
+ $fileModel = new TaskFile($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'test', 'tmp/foo', 10));
+
+ $this->container['objectStorage']
+ ->expects($this->once())
+ ->method('remove')
+ ->with('tmp/foo')
+ ->will($this->throwException(new \Kanboard\Core\ObjectStorage\ObjectStorageException('test')));
+
+ $this->assertFalse($fileModel->remove(1));
+ }
+
+ public function testRemoveImage()
+ {
+ $fileModel = new TaskFile($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'image.gif', 'tmp/image.gif', 10));
+
+ $this->container['objectStorage']
+ ->expects($this->at(0))
+ ->method('remove')
+ ->with('tmp/image.gif');
+
+ $this->container['objectStorage']
+ ->expects($this->at(1))
+ ->method('remove')
+ ->with('thumbnails'.DIRECTORY_SEPARATOR.'tmp/image.gif');
+
+ $this->assertTrue($fileModel->remove(1));
+ }
+
+ public function testRemoveAll()
+ {
+ $fileModel = new TaskFile($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+ $this->assertEquals(1, $fileModel->create(1, 'test', 'tmp/foo', 10));
+ $this->assertEquals(2, $fileModel->create(1, 'test', 'tmp/foo', 10));
+
+ $this->container['objectStorage']
+ ->expects($this->exactly(2))
+ ->method('remove')
+ ->with('tmp/foo');
+
+ $this->assertTrue($fileModel->removeAll(1));
+ }
+}
diff --git a/tests/units/Model/TaskFilterTest.php b/tests/units/Model/TaskFilterTest.php
index b668b7cc..daa193b2 100644
--- a/tests/units/Model/TaskFilterTest.php
+++ b/tests/units/Model/TaskFilterTest.php
@@ -6,6 +6,7 @@ use Kanboard\Model\Project;
use Kanboard\Model\User;
use Kanboard\Model\TaskFilter;
use Kanboard\Model\TaskCreation;
+use Kanboard\Model\TaskLink;
use Kanboard\Core\DateParser;
use Kanboard\Model\Category;
use Kanboard\Model\Subtask;
@@ -552,6 +553,65 @@ class TaskFilterTest extends Base
$this->assertEquals('task3', $tasks[0]['title']);
}
+ public function testSearchWithLink()
+ {
+ $p = new Project($this->container);
+ $u = new User($this->container);
+ $tc = new TaskCreation($this->container);
+ $tl = new TaskLink($this->container);
+ $tf = new TaskFilter($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'test')));
+ $this->assertEquals(2, $u->create(array('username' => 'bob', 'name' => 'Bob Ryan')));
+ $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'my task title is awesome', 'color_id' => 'light_green')));
+ $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'my task title is amazing', 'color_id' => 'blue')));
+ $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'Bob at work')));
+ $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'I have a bad feeling about that')));
+ $this->assertEquals(1, $tl->create(1, 2, 9)); // #1 is a milestone of #2
+ $this->assertEquals(3, $tl->create(2, 1, 2)); // #2 blocks #1
+ $this->assertEquals(5, $tl->create(3, 2, 2)); // #3 blocks #2
+
+ $tf->search('link:"is a milestone of"');
+ $tasks = $tf->findAll();
+ $this->assertNotEmpty($tasks);
+ $this->assertCount(1, $tasks);
+ $this->assertEquals('my task title is awesome', $tasks[0]['title']);
+
+ $tf->search('link:"is a milestone of" amazing');
+ $tasks = $tf->findAll();
+ $this->assertEmpty($tasks);
+
+ $tf->search('link:"unknown"');
+ $tasks = $tf->findAll();
+ $this->assertEmpty($tasks);
+
+ $tf->search('link:unknown');
+ $tasks = $tf->findAll();
+ $this->assertEmpty($tasks);
+
+ $tf->search('link:blocks amazing');
+ $tasks = $tf->findAll();
+ $this->assertNotEmpty($tasks);
+ $this->assertCount(1, $tasks);
+ $this->assertEquals('my task title is amazing', $tasks[0]['title']);
+
+ $tf->search('link:"is a milestone of" link:blocks');
+ $tasks = $tf->findAll();
+ $this->assertNotEmpty($tasks);
+ $this->assertCount(3, $tasks);
+ $this->assertEquals('my task title is awesome', $tasks[0]['title']);
+ $this->assertEquals('my task title is amazing', $tasks[1]['title']);
+ $this->assertEquals('Bob at work', $tasks[2]['title']);
+
+ $tf->search('link:"is a milestone of" link:blocks link:unknown');
+ $tasks = $tf->findAll();
+ $this->assertNotEmpty($tasks);
+ $this->assertCount(3, $tasks);
+ $this->assertEquals('my task title is awesome', $tasks[0]['title']);
+ $this->assertEquals('my task title is amazing', $tasks[1]['title']);
+ $this->assertEquals('Bob at work', $tasks[2]['title']);
+ }
+
public function testCopy()
{
$tf = new TaskFilter($this->container);
diff --git a/tests/units/Model/TaskFinderTest.php b/tests/units/Model/TaskFinderTest.php
index e22f14e1..b21a4ea3 100644
--- a/tests/units/Model/TaskFinderTest.php
+++ b/tests/units/Model/TaskFinderTest.php
@@ -6,7 +6,6 @@ use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
use Kanboard\Model\Category;
use Kanboard\Model\User;
diff --git a/tests/units/Model/TaskLinkTest.php b/tests/units/Model/TaskLinkTest.php
index 4db42d99..192a4298 100644
--- a/tests/units/Model/TaskLinkTest.php
+++ b/tests/units/Model/TaskLinkTest.php
@@ -194,53 +194,4 @@ class TaskLinkTest extends Base
$links = $tl->getAll(2);
$this->assertEmpty($links);
}
-
- public function testValidation()
- {
- $tl = new TaskLink($this->container);
- $p = new Project($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' => 'A')));
- $this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'B')));
-
- $links = $tl->getAll(1);
- $this->assertEmpty($links);
-
- $links = $tl->getAll(2);
- $this->assertEmpty($links);
-
- // Check creation
- $r = $tl->validateCreation(array('task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 2));
- $this->assertTrue($r[0]);
-
- $r = $tl->validateCreation(array('task_id' => 1, 'link_id' => 1));
- $this->assertFalse($r[0]);
-
- $r = $tl->validateCreation(array('task_id' => 1, 'opposite_task_id' => 2));
- $this->assertFalse($r[0]);
-
- $r = $tl->validateCreation(array('task_id' => 1, 'opposite_task_id' => 2));
- $this->assertFalse($r[0]);
-
- $r = $tl->validateCreation(array('task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 1));
- $this->assertFalse($r[0]);
-
- // Check modification
- $r = $tl->validateModification(array('id' => 1, 'task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 2));
- $this->assertTrue($r[0]);
-
- $r = $tl->validateModification(array('id' => 1, 'task_id' => 1, 'link_id' => 1));
- $this->assertFalse($r[0]);
-
- $r = $tl->validateModification(array('id' => 1, 'task_id' => 1, 'opposite_task_id' => 2));
- $this->assertFalse($r[0]);
-
- $r = $tl->validateModification(array('id' => 1, 'task_id' => 1, 'opposite_task_id' => 2));
- $this->assertFalse($r[0]);
-
- $r = $tl->validateModification(array('id' => 1, 'task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 1));
- $this->assertFalse($r[0]);
- }
}
diff --git a/tests/units/Model/TaskMetadataTest.php b/tests/units/Model/TaskMetadataTest.php
index 9ce7d6be..2683c297 100644
--- a/tests/units/Model/TaskMetadataTest.php
+++ b/tests/units/Model/TaskMetadataTest.php
@@ -33,5 +33,10 @@ class TaskMetadataTest extends Base
$this->assertEquals(array('key1' => 'value2'), $tm->getAll(1));
$this->assertEquals(array('key1' => 'value1', 'key2' => 'value2'), $tm->getAll(2));
+
+ $this->assertTrue($tm->remove(2, 'key1'));
+ $this->assertFalse($tm->remove(2, 'key1'));
+
+ $this->assertEquals(array('key2' => 'value2'), $tm->getAll(2));
}
}
diff --git a/tests/units/Model/TaskModificationTest.php b/tests/units/Model/TaskModificationTest.php
index 49b51f9b..119201f0 100644
--- a/tests/units/Model/TaskModificationTest.php
+++ b/tests/units/Model/TaskModificationTest.php
@@ -8,7 +8,6 @@ use Kanboard\Model\TaskModification;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\TaskStatus;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
class TaskModificationTest extends Base
{
@@ -42,6 +41,24 @@ class TaskModificationTest extends Base
$this->assertEquals(1, $event_data['owner_id']);
}
+ public function testThatNoEventAreFiredWhenNoChanges()
+ {
+ $p = new Project($this->container);
+ $tc = new TaskCreation($this->container);
+ $tm = new TaskModification($this->container);
+ $tf = new TaskFinder($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'test')));
+ $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1)));
+
+ $this->container['dispatcher']->addListener(Task::EVENT_CREATE_UPDATE, array($this, 'onCreateUpdate'));
+ $this->container['dispatcher']->addListener(Task::EVENT_UPDATE, array($this, 'onUpdate'));
+
+ $this->assertTrue($tm->update(array('id' => 1, 'title' => 'test')));
+
+ $this->assertEmpty($this->container['dispatcher']->getCalledListeners());
+ }
+
public function testChangeTitle()
{
$p = new Project($this->container);
diff --git a/tests/units/Model/TaskMovedDateSubscriberTest.php b/tests/units/Model/TaskMovedDateSubscriberTest.php
deleted file mode 100644
index 0dd6e995..00000000
--- a/tests/units/Model/TaskMovedDateSubscriberTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Model\TaskPosition;
-use Kanboard\Model\TaskCreation;
-use Kanboard\Model\TaskFinder;
-use Kanboard\Model\Project;
-use Kanboard\Model\Swimlane;
-use Kanboard\Subscriber\TaskMovedDateSubscriber;
-use Symfony\Component\EventDispatcher\EventDispatcher;
-
-class TaskMovedDateSubscriberTest extends Base
-{
- public function testMoveTaskAnotherColumn()
- {
- $tp = new TaskPosition($this->container);
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $tf = new TaskFinder($this->container);
-
- $this->container['dispatcher'] = new EventDispatcher;
- $this->container['dispatcher']->addSubscriber(new TaskMovedDateSubscriber($this->container));
-
- $now = time();
-
- $this->assertEquals(1, $p->create(array('name' => 'Project #1')));
- $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1)));
-
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals($now, $task['date_moved'], '', 1);
-
- sleep(1);
-
- $this->assertTrue($tp->movePosition(1, 1, 2, 1));
-
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertNotEquals($now, $task['date_moved']);
- }
-
- public function testMoveTaskAnotherSwimlane()
- {
- $tp = new TaskPosition($this->container);
- $tc = new TaskCreation($this->container);
- $p = new Project($this->container);
- $tf = new TaskFinder($this->container);
- $s = new Swimlane($this->container);
-
- $this->container['dispatcher'] = new EventDispatcher;
- $this->container['dispatcher']->addSubscriber(new TaskMovedDateSubscriber($this->container));
-
- $now = time();
-
- $this->assertEquals(1, $p->create(array('name' => 'Project #1')));
- $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'S1')));
- $this->assertEquals(2, $s->create(array('project_id' => 1, 'name' => 'S2')));
- $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1)));
-
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertEquals($now, $task['date_moved'], '', 1);
- $this->assertEquals(1, $task['column_id']);
- $this->assertEquals(0, $task['swimlane_id']);
-
- sleep(1);
-
- $this->assertTrue($tp->movePosition(1, 1, 2, 1, 2));
-
- $task = $tf->getById(1);
- $this->assertNotEmpty($task);
- $this->assertNotEquals($now, $task['date_moved']);
- $this->assertEquals(2, $task['column_id']);
- $this->assertEquals(2, $task['swimlane_id']);
- }
-}
diff --git a/tests/units/Model/TaskPermissionTest.php b/tests/units/Model/TaskPermissionTest.php
index 52a36549..0b093bbb 100644
--- a/tests/units/Model/TaskPermissionTest.php
+++ b/tests/units/Model/TaskPermissionTest.php
@@ -9,7 +9,7 @@ use Kanboard\Model\TaskPermission;
use Kanboard\Model\Project;
use Kanboard\Model\Category;
use Kanboard\Model\User;
-use Kanboard\Model\UserSession;
+use Kanboard\Core\User\UserSession;
class TaskPermissionTest extends Base
{
@@ -33,7 +33,7 @@ class TaskPermissionTest extends Base
// User #1 can remove everything
$user = $u->getbyId(1);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(1);
$this->assertNotEmpty($task);
@@ -42,7 +42,7 @@ class TaskPermissionTest extends Base
// User #2 can't remove the task #1
$user = $u->getbyId(2);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(1);
$this->assertNotEmpty($task);
@@ -51,7 +51,7 @@ class TaskPermissionTest extends Base
// User #1 can remove everything
$user = $u->getbyId(1);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(2);
$this->assertNotEmpty($task);
@@ -60,7 +60,7 @@ class TaskPermissionTest extends Base
// User #2 can remove his own task
$user = $u->getbyId(2);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(2);
$this->assertNotEmpty($task);
@@ -69,7 +69,7 @@ class TaskPermissionTest extends Base
// User #1 can remove everything
$user = $u->getbyId(1);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(3);
$this->assertNotEmpty($task);
@@ -78,7 +78,7 @@ class TaskPermissionTest extends Base
// User #2 can't remove the task #3
$user = $u->getbyId(2);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(3);
$this->assertNotEmpty($task);
@@ -87,7 +87,7 @@ class TaskPermissionTest extends Base
// User #1 can remove everything
$user = $u->getbyId(1);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(4);
$this->assertNotEmpty($task);
@@ -96,7 +96,7 @@ class TaskPermissionTest extends Base
// User #2 can't remove the task #4
$user = $u->getbyId(2);
$this->assertNotEmpty($user);
- $us->refresh($user);
+ $us->initialize($user);
$task = $tf->getbyId(4);
$this->assertNotEmpty($task);
diff --git a/tests/units/Model/TaskPositionTest.php b/tests/units/Model/TaskPositionTest.php
index 42612f44..28145a66 100644
--- a/tests/units/Model/TaskPositionTest.php
+++ b/tests/units/Model/TaskPositionTest.php
@@ -3,7 +3,7 @@
require_once __DIR__.'/../Base.php';
use Kanboard\Model\Task;
-use Kanboard\Model\Board;
+use Kanboard\Model\Column;
use Kanboard\Model\TaskStatus;
use Kanboard\Model\TaskPosition;
use Kanboard\Model\TaskCreation;
@@ -21,23 +21,23 @@ class TaskPositionTest extends Base
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
- $b = new Board($this->container);
+ $columnModel = new Column($this->container);
$this->assertEquals(1, $p->create(array('name' => 'Project #1')));
$this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1)));
- $this->assertEquals(0, $t->getProgress($tf->getById(1), $b->getColumnsList(1)));
+ $this->assertEquals(0, $t->getProgress($tf->getById(1), $columnModel->getList(1)));
$this->assertTrue($tp->movePosition(1, 1, 2, 1));
- $this->assertEquals(25, $t->getProgress($tf->getById(1), $b->getColumnsList(1)));
+ $this->assertEquals(25, $t->getProgress($tf->getById(1), $columnModel->getList(1)));
$this->assertTrue($tp->movePosition(1, 1, 3, 1));
- $this->assertEquals(50, $t->getProgress($tf->getById(1), $b->getColumnsList(1)));
+ $this->assertEquals(50, $t->getProgress($tf->getById(1), $columnModel->getList(1)));
$this->assertTrue($tp->movePosition(1, 1, 4, 1));
- $this->assertEquals(75, $t->getProgress($tf->getById(1), $b->getColumnsList(1)));
+ $this->assertEquals(75, $t->getProgress($tf->getById(1), $columnModel->getList(1)));
$this->assertTrue($ts->close(1));
- $this->assertEquals(100, $t->getProgress($tf->getById(1), $b->getColumnsList(1)));
+ $this->assertEquals(100, $t->getProgress($tf->getById(1), $columnModel->getList(1)));
}
public function testMoveTaskToWrongPosition()
@@ -106,7 +106,7 @@ class TaskPositionTest extends Base
$this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1)));
$this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1)));
- // We move the task 2 to the column 3
+ // We move the task 1 to the column 3
$this->assertTrue($tp->movePosition(1, 1, 3, 1));
// Check tasks position
@@ -235,7 +235,7 @@ class TaskPositionTest extends Base
$this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1)));
$this->assertEquals(4, $tc->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1)));
- // Move the last task to the bottom
+ // Move the first task to the bottom
$this->assertTrue($tp->movePosition(1, 1, 1, 4));
// Check tasks position
diff --git a/tests/units/Model/TaskStatusTest.php b/tests/units/Model/TaskStatusTest.php
index de08ffb3..86f31d70 100644
--- a/tests/units/Model/TaskStatusTest.php
+++ b/tests/units/Model/TaskStatusTest.php
@@ -2,16 +2,51 @@
require_once __DIR__.'/../Base.php';
+use Kanboard\Model\Swimlane;
use Kanboard\Model\Subtask;
use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\TaskStatus;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
class TaskStatusTest extends Base
{
+ public function testCloseBySwimlaneAndColumn()
+ {
+ $tc = new TaskCreation($this->container);
+ $tf = new TaskFinder($this->container);
+ $ts = new TaskStatus($this->container);
+ $p = new Project($this->container);
+ $s = new Swimlane($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'test')));
+ $this->assertEquals(1, $s->create(array('name' => 'test', 'project_id' => 1)));
+ $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1)));
+ $this->assertEquals(2, $tc->create(array('title' => 'test', 'project_id' => 1)));
+ $this->assertEquals(3, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 2)));
+ $this->assertEquals(4, $tc->create(array('title' => 'test', 'project_id' => 1, 'swimlane_id' => 1)));
+
+ $this->assertEquals(2, $tf->countByColumnAndSwimlaneId(1, 1, 0));
+ $this->assertEquals(1, $tf->countByColumnAndSwimlaneId(1, 1, 1));
+ $this->assertEquals(1, $tf->countByColumnAndSwimlaneId(1, 2, 0));
+
+ $ts->closeTasksBySwimlaneAndColumn(0, 1);
+ $this->assertEquals(0, $tf->countByColumnAndSwimlaneId(1, 1, 0));
+ $this->assertEquals(1, $tf->countByColumnAndSwimlaneId(1, 1, 1));
+ $this->assertEquals(1, $tf->countByColumnAndSwimlaneId(1, 2, 0));
+
+ $ts->closeTasksBySwimlaneAndColumn(1, 1);
+ $this->assertEquals(0, $tf->countByColumnAndSwimlaneId(1, 1, 0));
+ $this->assertEquals(0, $tf->countByColumnAndSwimlaneId(1, 1, 1));
+ $this->assertEquals(1, $tf->countByColumnAndSwimlaneId(1, 2, 0));
+
+ $ts->closeTasksBySwimlaneAndColumn(0, 2);
+ $this->assertEquals(0, $tf->countByColumnAndSwimlaneId(1, 1, 0));
+ $this->assertEquals(0, $tf->countByColumnAndSwimlaneId(1, 1, 1));
+ $this->assertEquals(0, $tf->countByColumnAndSwimlaneId(1, 2, 0));
+ }
+
public function testStatus()
{
$tc = new TaskCreation($this->container);
diff --git a/tests/units/Model/TaskTest.php b/tests/units/Model/TaskTest.php
index 192dc098..60e752e5 100644
--- a/tests/units/Model/TaskTest.php
+++ b/tests/units/Model/TaskTest.php
@@ -7,7 +7,6 @@ use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\TaskStatus;
use Kanboard\Model\Project;
-use Kanboard\Model\ProjectPermission;
use Kanboard\Model\Category;
use Kanboard\Model\User;
diff --git a/tests/units/Model/UserLockingTest.php b/tests/units/Model/UserLockingTest.php
new file mode 100644
index 00000000..c743f8eb
--- /dev/null
+++ b/tests/units/Model/UserLockingTest.php
@@ -0,0 +1,43 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\UserLocking;
+
+class UserLockingTest extends Base
+{
+ public function testFailedLogin()
+ {
+ $u = new UserLocking($this->container);
+
+ $this->assertEquals(0, $u->getFailedLogin('admin'));
+ $this->assertEquals(0, $u->getFailedLogin('not_found'));
+
+ $this->assertTrue($u->incrementFailedLogin('admin'));
+ $this->assertTrue($u->incrementFailedLogin('admin'));
+
+ $this->assertEquals(2, $u->getFailedLogin('admin'));
+ $this->assertTrue($u->resetFailedLogin('admin'));
+ $this->assertEquals(0, $u->getFailedLogin('admin'));
+ }
+
+ public function testLocking()
+ {
+ $u = new UserLocking($this->container);
+
+ $this->assertFalse($u->isLocked('admin'));
+ $this->assertFalse($u->isLocked('not_found'));
+ $this->assertTrue($u->lock('admin', 1));
+ $this->assertTrue($u->isLocked('admin'));
+ }
+
+ public function testCaptcha()
+ {
+ $u = new UserLocking($this->container);
+ $this->assertTrue($u->incrementFailedLogin('admin'));
+ $this->assertFalse($u->hasCaptcha('admin', 2));
+
+ $this->assertTrue($u->incrementFailedLogin('admin'));
+ $this->assertTrue($u->hasCaptcha('admin', 2));
+ }
+}
diff --git a/tests/units/Model/UserMentionTest.php b/tests/units/Model/UserMentionTest.php
new file mode 100644
index 00000000..d50c9285
--- /dev/null
+++ b/tests/units/Model/UserMentionTest.php
@@ -0,0 +1,114 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Core\Security\Role;
+use Kanboard\Event\GenericEvent;
+use Kanboard\Model\User;
+use Kanboard\Model\Task;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\UserMention;
+
+class UserMentionTest extends Base
+{
+ public function testGetMentionedUsersWithNoMentions()
+ {
+ $userModel = new User($this->container);
+ $userMentionModel = new UserMention($this->container);
+
+ $this->assertNotFalse($userModel->create(array('username' => 'user1')));
+ $this->assertEmpty($userMentionModel->getMentionedUsers('test'));
+ }
+
+ public function testGetMentionedUsersWithNotficationDisabled()
+ {
+ $userModel = new User($this->container);
+ $userMentionModel = new UserMention($this->container);
+
+ $this->assertNotFalse($userModel->create(array('username' => 'user1')));
+ $this->assertEmpty($userMentionModel->getMentionedUsers('test @user1'));
+ }
+
+ public function testGetMentionedUsersWithNotficationEnabled()
+ {
+ $userModel = new User($this->container);
+ $userMentionModel = new UserMention($this->container);
+
+ $this->assertNotFalse($userModel->create(array('username' => 'user1')));
+ $this->assertNotFalse($userModel->create(array('username' => 'user2', 'name' => 'Foobar', 'notifications_enabled' => 1)));
+
+ $users = $userMentionModel->getMentionedUsers('test @user2');
+ $this->assertCount(1, $users);
+ $this->assertEquals('user2', $users[0]['username']);
+ $this->assertEquals('Foobar', $users[0]['name']);
+ $this->assertEquals('', $users[0]['email']);
+ $this->assertEquals('', $users[0]['language']);
+ }
+
+ public function testGetMentionedUsersWithNotficationEnabledAndUserLoggedIn()
+ {
+ $this->container['sessionStorage']->user = array('id' => 3);
+ $userModel = new User($this->container);
+ $userMentionModel = new UserMention($this->container);
+
+ $this->assertNotFalse($userModel->create(array('username' => 'user1')));
+ $this->assertNotFalse($userModel->create(array('username' => 'user2', 'name' => 'Foobar', 'notifications_enabled' => 1)));
+
+ $this->assertEmpty($userMentionModel->getMentionedUsers('test @user2'));
+ }
+
+ public function testFireEventsWithMultipleMentions()
+ {
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $projectModel = new Project($this->container);
+ $userModel = new User($this->container);
+ $userMentionModel = new UserMention($this->container);
+ $event = new GenericEvent(array('project_id' => 1));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User 1', 'notifications_enabled' => 1)));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User 2', 'notifications_enabled' => 1)));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'P1')));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+
+ $this->container['dispatcher']->addListener(Task::EVENT_USER_MENTION, array($this, 'onUserMention'));
+
+ $userMentionModel->fireEvents('test @user1 @user2', Task::EVENT_USER_MENTION, $event);
+
+ $called = $this->container['dispatcher']->getCalledListeners();
+ $this->assertArrayHasKey(Task::EVENT_USER_MENTION.'.UserMentionTest::onUserMention', $called);
+ }
+
+ public function testFireEventsWithNoProjectId()
+ {
+ $projectUserRoleModel = new ProjectUserRole($this->container);
+ $projectModel = new Project($this->container);
+ $taskCreationModel = new TaskCreation($this->container);
+ $userModel = new User($this->container);
+ $userMentionModel = new UserMention($this->container);
+ $event = new GenericEvent(array('task_id' => 1));
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'name' => 'User 1', 'notifications_enabled' => 1)));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'name' => 'User 2', 'notifications_enabled' => 1)));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'P1')));
+ $this->assertTrue($projectUserRoleModel->addUser(1, 3, Role::PROJECT_MEMBER));
+
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'Task 1')));
+
+ $this->container['dispatcher']->addListener(Task::EVENT_USER_MENTION, array($this, 'onUserMention'));
+
+ $userMentionModel->fireEvents('test @user1 @user2', Task::EVENT_USER_MENTION, $event);
+
+ $called = $this->container['dispatcher']->getCalledListeners();
+ $this->assertArrayHasKey(Task::EVENT_USER_MENTION.'.UserMentionTest::onUserMention', $called);
+ }
+
+ public function onUserMention($event)
+ {
+ $this->assertInstanceOf('Kanboard\Event\GenericEvent', $event);
+ $this->assertEquals(array('id' => '3', 'username' => 'user2', 'name' => 'User 2', 'email' => null, 'language' => null), $event['mention']);
+ }
+}
diff --git a/tests/units/Model/UserMetadataTest.php b/tests/units/Model/UserMetadataTest.php
index cc1fff12..457f1fb2 100644
--- a/tests/units/Model/UserMetadataTest.php
+++ b/tests/units/Model/UserMetadataTest.php
@@ -29,5 +29,10 @@ class UserMetadataTest extends Base
$this->assertEquals(array('key1' => 'value2'), $m->getAll(1));
$this->assertEquals(array('key1' => 'value1', 'key2' => 'value2'), $m->getAll(2));
+
+ $this->assertTrue($m->remove(2, 'key1'));
+ $this->assertFalse($m->remove(2, 'key1'));
+
+ $this->assertEquals(array('key2' => 'value2'), $m->getAll(2));
}
}
diff --git a/tests/units/Model/UserNotificationTest.php b/tests/units/Model/UserNotificationTest.php
index 729667de..e1928661 100644
--- a/tests/units/Model/UserNotificationTest.php
+++ b/tests/units/Model/UserNotificationTest.php
@@ -7,14 +7,18 @@ use Kanboard\Model\TaskCreation;
use Kanboard\Model\Subtask;
use Kanboard\Model\Comment;
use Kanboard\Model\User;
-use Kanboard\Model\File;
+use Kanboard\Model\Group;
+use Kanboard\Model\GroupMember;
use Kanboard\Model\Project;
-use Kanboard\Model\Task;
use Kanboard\Model\ProjectPermission;
+use Kanboard\Model\Task;
+use Kanboard\Model\ProjectUserRole;
+use Kanboard\Model\ProjectGroupRole;
use Kanboard\Model\UserNotification;
use Kanboard\Model\UserNotificationFilter;
use Kanboard\Model\UserNotificationType;
use Kanboard\Subscriber\UserNotificationSubscriber;
+use Kanboard\Core\Security\Role;
class UserNotificationTest extends Base
{
@@ -23,11 +27,11 @@ class UserNotificationTest extends Base
$u = new User($this->container);
$p = new Project($this->container);
$n = new UserNotification($this->container);
- $pp = new ProjectPermission($this->container);
+ $pp = new ProjectUserRole($this->container);
$this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
$this->assertEquals(2, $u->create(array('username' => 'user1')));
- $this->assertTrue($pp->addMember(1, 2));
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MEMBER));
$this->assertEmpty($n->getUsersWithNotificationEnabled(1));
$n->enableNotification(2);
@@ -96,12 +100,48 @@ class UserNotificationTest extends Base
$this->assertEquals(array(1), $settings['notification_projects']);
}
+ public function testGetGroupMembersWithNotificationEnabled()
+ {
+ $userModel = new User($this->container);
+ $groupModel = new Group($this->container);
+ $groupMemberModel = new GroupMember($this->container);
+ $projectModel = new Project($this->container);
+ $userNotificationModel = new UserNotification($this->container);
+ $projectGroupRole = new ProjectGroupRole($this->container);
+ $projectUserRole = new ProjectUserRole($this->container);
+
+ $this->assertEquals(2, $userModel->create(array('username' => 'user1', 'email' => 'user1@here', 'notifications_enabled' => 1)));
+ $this->assertEquals(3, $userModel->create(array('username' => 'user2', 'email' => '', 'notifications_enabled' => 1)));
+ $this->assertEquals(4, $userModel->create(array('username' => 'user3')));
+
+ $this->assertEquals(1, $groupModel->create('G1'));
+ $this->assertEquals(2, $groupModel->create('G2'));
+
+ $this->assertTrue($groupMemberModel->addUser(1, 2));
+ $this->assertTrue($groupMemberModel->addUser(1, 3));
+ $this->assertTrue($groupMemberModel->addUser(1, 4));
+ $this->assertTrue($groupMemberModel->addUser(2, 2));
+ $this->assertTrue($groupMemberModel->addUser(2, 3));
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'P1')));
+
+ $this->assertTrue($projectGroupRole->addGroup(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($projectGroupRole->addGroup(1, 2, Role::PROJECT_VIEWER));
+
+ $this->assertTrue($projectUserRole->addUser(1, 2, Role::PROJECT_MEMBER));
+
+ $users = $userNotificationModel->getUsersWithNotificationEnabled(1);
+ $this->assertCount(2, $users);
+ $this->assertEquals('user1', $users[0]['username']);
+ $this->assertEquals('user2', $users[1]['username']);
+ }
+
public function testGetProjectMembersWithNotifications()
{
$u = new User($this->container);
$p = new Project($this->container);
$n = new UserNotification($this->container);
- $pp = new ProjectPermission($this->container);
+ $pp = new ProjectUserRole($this->container);
$this->assertEquals(1, $p->create(array('name' => 'UnitTest1')));
@@ -118,16 +158,16 @@ class UserNotificationTest extends Base
$this->assertNotFalse($u->create(array('username' => 'user4')));
// Nobody is member of any projects
- $this->assertEmpty($pp->getMembers(1));
+ $this->assertEmpty($pp->getUsers(1));
$this->assertEmpty($n->getUsersWithNotificationEnabled(1));
// 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->addUser(1, 1, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(1, 2, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(1, 3, Role::PROJECT_MEMBER));
+ $this->assertTrue($pp->addUser(1, 4, Role::PROJECT_MEMBER));
- $this->assertNotEmpty($pp->getMembers(1));
+ $this->assertNotEmpty($pp->getUsers(1));
$users = $n->getUsersWithNotificationEnabled(1);
$this->assertNotEmpty($users);
diff --git a/tests/units/Model/UserSessionTest.php b/tests/units/Model/UserSessionTest.php
deleted file mode 100644
index 66f6faa7..00000000
--- a/tests/units/Model/UserSessionTest.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php
-
-require_once __DIR__.'/../Base.php';
-
-use Kanboard\Core\Session;
-use Kanboard\Model\UserSession;
-
-class UserSessionTest extends Base
-{
- public function testIsAdmin()
- {
- $s = new Session;
- $us = new UserSession($this->container);
-
- $this->assertFalse($us->isAdmin());
-
- $s['user'] = array();
- $this->assertFalse($us->isAdmin());
-
- $s['user'] = array('is_admin' => '1');
- $this->assertFalse($us->isAdmin());
-
- $s['user'] = array('is_admin' => false);
- $this->assertFalse($us->isAdmin());
-
- $s['user'] = array('is_admin' => '2');
- $this->assertFalse($us->isAdmin());
-
- $s['user'] = array('is_admin' => true);
- $this->assertTrue($us->isAdmin());
- }
-}
diff --git a/tests/units/Model/UserTest.php b/tests/units/Model/UserTest.php
index 90a80954..e411da0c 100644
--- a/tests/units/Model/UserTest.php
+++ b/tests/units/Model/UserTest.php
@@ -9,34 +9,10 @@ use Kanboard\Model\Task;
use Kanboard\Model\TaskCreation;
use Kanboard\Model\TaskFinder;
use Kanboard\Model\Project;
+use Kanboard\Core\Security\Role;
class UserTest extends Base
{
- public function testFailedLogin()
- {
- $u = new User($this->container);
-
- $this->assertEquals(0, $u->getFailedLogin('admin'));
- $this->assertEquals(0, $u->getFailedLogin('not_found'));
-
- $this->assertTrue($u->incrementFailedLogin('admin'));
- $this->assertTrue($u->incrementFailedLogin('admin'));
-
- $this->assertEquals(2, $u->getFailedLogin('admin'));
- $this->assertTrue($u->resetFailedLogin('admin'));
- $this->assertEquals(0, $u->getFailedLogin('admin'));
- }
-
- public function testLocking()
- {
- $u = new User($this->container);
-
- $this->assertFalse($u->isLocked('admin'));
- $this->assertFalse($u->isLocked('not_found'));
- $this->assertTrue($u->lock('admin', 1));
- $this->assertTrue($u->isLocked('admin'));
- }
-
public function testGetByEmail()
{
$u = new User($this->container);
@@ -47,33 +23,27 @@ class UserTest extends Base
$this->assertEmpty($u->getByEmail(''));
}
- public function testGetByGitlabId()
+ public function testGetByExternalId()
{
$u = new User($this->container);
$this->assertNotFalse($u->create(array('username' => 'user1', 'password' => '123456', 'gitlab_id' => '1234')));
- $this->assertNotEmpty($u->getByGitlabId('1234'));
- $this->assertEmpty($u->getByGitlabId(''));
- }
+ $this->assertNotEmpty($u->getByExternalId('gitlab_id', '1234'));
+ $this->assertEmpty($u->getByExternalId('gitlab_id', ''));
- public function testGetByGithubId()
- {
$u = new User($this->container);
- $this->assertNotFalse($u->create(array('username' => 'user1', 'password' => '123456', 'github_id' => 'plop')));
- $this->assertNotFalse($u->create(array('username' => 'user2', 'password' => '123456', 'github_id' => '')));
+ $this->assertNotFalse($u->create(array('username' => 'user2', 'password' => '123456', 'github_id' => 'plop')));
+ $this->assertNotFalse($u->create(array('username' => 'user3', 'password' => '123456', 'github_id' => '')));
- $this->assertNotEmpty($u->getByGithubId('plop'));
- $this->assertEmpty($u->getByGithubId(''));
- }
+ $this->assertNotEmpty($u->getByExternalId('github_id', 'plop'));
+ $this->assertEmpty($u->getByExternalId('github_id', ''));
- public function testGetByGoogleId()
- {
$u = new User($this->container);
- $this->assertNotFalse($u->create(array('username' => 'user1', 'password' => '123456', 'google_id' => '1234')));
- $this->assertNotFalse($u->create(array('username' => 'user2', 'password' => '123456', 'google_id' => '')));
+ $this->assertNotFalse($u->create(array('username' => 'user4', 'password' => '123456', 'google_id' => '1234')));
+ $this->assertNotFalse($u->create(array('username' => 'user5', 'password' => '123456', 'google_id' => '')));
- $this->assertNotEmpty($u->getByGoogleId('1234'));
- $this->assertEmpty($u->getByGoogleId(''));
+ $this->assertNotEmpty($u->getByExternalId('google_id', '1234'));
+ $this->assertEmpty($u->getByExternalId('google_id', ''));
}
public function testGetByToken()
@@ -126,13 +96,14 @@ class UserTest extends Base
$this->assertEquals('you', $users[2]['username']);
}
- public function testGetList()
+ public function testGetActiveUsersList()
{
$u = new User($this->container);
$this->assertEquals(2, $u->create(array('username' => 'you')));
$this->assertEquals(3, $u->create(array('username' => 'me', 'name' => 'Me too')));
+ $this->assertEquals(4, $u->create(array('username' => 'foobar', 'is_active' => 0)));
- $users = $u->getList();
+ $users = $u->getActiveUsersList();
$expected = array(
1 => 'admin',
@@ -142,7 +113,7 @@ class UserTest extends Base
$this->assertEquals($expected, $users);
- $users = $u->getList(true);
+ $users = $u->getActiveUsersList(true);
$expected = array(
User::EVERYBODY_ID => 'Everybody',
@@ -197,7 +168,7 @@ class UserTest extends Base
'password' => '1234',
'confirmation' => '1234',
'name' => 'me',
- 'is_admin' => '',
+ 'role' => Role::APP_ADMIN,
);
$u->prepare($input);
@@ -207,9 +178,6 @@ class UserTest extends Base
$this->assertNotEquals('1234', $input['password']);
$this->assertNotEmpty($input['password']);
- $this->assertArrayHasKey('is_admin', $input);
- $this->assertInternalType('integer', $input['is_admin']);
-
$input = array(
'username' => 'user1',
'password' => '1234',
@@ -273,8 +241,8 @@ class UserTest extends Base
$u = new User($this->container);
$this->assertEquals(2, $u->create(array('username' => 'user #1', 'password' => '123456', 'name' => 'User')));
$this->assertEquals(3, $u->create(array('username' => 'user #2', 'is_ldap_user' => 1)));
- $this->assertEquals(4, $u->create(array('username' => 'user #3', 'is_project_admin' => 1)));
- $this->assertEquals(5, $u->create(array('username' => 'user #4', 'gitlab_id' => '')));
+ $this->assertEquals(4, $u->create(array('username' => 'user #3', 'role' => Role::APP_MANAGER)));
+ $this->assertEquals(5, $u->create(array('username' => 'user #4', 'gitlab_id' => '', 'role' => Role::APP_ADMIN)));
$this->assertEquals(6, $u->create(array('username' => 'user #5', 'gitlab_id' => '1234')));
$this->assertFalse($u->create(array('username' => 'user #1')));
@@ -283,7 +251,7 @@ class UserTest extends Base
$this->assertTrue(is_array($user));
$this->assertEquals('admin', $user['username']);
$this->assertEquals('', $user['name']);
- $this->assertEquals(1, $user['is_admin']);
+ $this->assertEquals(Role::APP_ADMIN, $user['role']);
$this->assertEquals(0, $user['is_ldap_user']);
$user = $u->getById(2);
@@ -291,7 +259,7 @@ class UserTest extends Base
$this->assertTrue(is_array($user));
$this->assertEquals('user #1', $user['username']);
$this->assertEquals('User', $user['name']);
- $this->assertEquals(0, $user['is_admin']);
+ $this->assertEquals(Role::APP_USER, $user['role']);
$this->assertEquals(0, $user['is_ldap_user']);
$user = $u->getById(3);
@@ -299,27 +267,28 @@ class UserTest extends Base
$this->assertTrue(is_array($user));
$this->assertEquals('user #2', $user['username']);
$this->assertEquals('', $user['name']);
- $this->assertEquals(0, $user['is_admin']);
+ $this->assertEquals(Role::APP_USER, $user['role']);
$this->assertEquals(1, $user['is_ldap_user']);
$user = $u->getById(4);
$this->assertNotFalse($user);
$this->assertTrue(is_array($user));
$this->assertEquals('user #3', $user['username']);
- $this->assertEquals(0, $user['is_admin']);
- $this->assertEquals(1, $user['is_project_admin']);
+ $this->assertEquals(Role::APP_MANAGER, $user['role']);
$user = $u->getById(5);
$this->assertNotFalse($user);
$this->assertTrue(is_array($user));
$this->assertEquals('user #4', $user['username']);
$this->assertEquals('', $user['gitlab_id']);
+ $this->assertEquals(Role::APP_ADMIN, $user['role']);
$user = $u->getById(6);
$this->assertNotFalse($user);
$this->assertTrue(is_array($user));
$this->assertEquals('user #5', $user['username']);
$this->assertEquals('1234', $user['gitlab_id']);
+ $this->assertEquals(Role::APP_USER, $user['role']);
}
public function testUpdate()
@@ -336,7 +305,7 @@ class UserTest extends Base
$this->assertTrue(is_array($user));
$this->assertEquals('biloute', $user['username']);
$this->assertEquals('Toto', $user['name']);
- $this->assertEquals(0, $user['is_admin']);
+ $this->assertEquals(Role::APP_USER, $user['role']);
$this->assertEquals(0, $user['is_ldap_user']);
$user = $u->getById(3);
@@ -423,4 +392,24 @@ class UserTest extends Base
$this->assertEquals('toto', $user['username']);
$this->assertEmpty($user['token']);
}
+
+ public function testEnableDisable()
+ {
+ $userModel = new User($this->container);
+ $this->assertEquals(2, $userModel->create(array('username' => 'toto')));
+
+ $this->assertTrue($userModel->isActive(2));
+ $user = $userModel->getById(2);
+ $this->assertEquals(1, $user['is_active']);
+
+ $this->assertTrue($userModel->disable(2));
+ $user = $userModel->getById(2);
+ $this->assertEquals(0, $user['is_active']);
+ $this->assertFalse($userModel->isActive(2));
+
+ $this->assertTrue($userModel->enable(2));
+ $user = $userModel->getById(2);
+ $this->assertEquals(1, $user['is_active']);
+ $this->assertTrue($userModel->isActive(2));
+ }
}
diff --git a/tests/units/Model/UserUnreadNotificationTest.php b/tests/units/Model/UserUnreadNotificationTest.php
index bf274d95..62889bf0 100644
--- a/tests/units/Model/UserUnreadNotificationTest.php
+++ b/tests/units/Model/UserUnreadNotificationTest.php
@@ -7,7 +7,6 @@ use Kanboard\Model\TaskCreation;
use Kanboard\Model\Subtask;
use Kanboard\Model\Comment;
use Kanboard\Model\User;
-use Kanboard\Model\File;
use Kanboard\Model\Task;
use Kanboard\Model\Project;
use Kanboard\Model\UserUnreadNotification;
diff --git a/tests/units/Notification/MailTest.php b/tests/units/Notification/MailTest.php
index 3aa1a39c..7dc6aaef 100644
--- a/tests/units/Notification/MailTest.php
+++ b/tests/units/Notification/MailTest.php
@@ -7,10 +7,9 @@ use Kanboard\Model\TaskCreation;
use Kanboard\Model\Subtask;
use Kanboard\Model\Comment;
use Kanboard\Model\User;
-use Kanboard\Model\File;
+use Kanboard\Model\TaskFile;
use Kanboard\Model\Project;
use Kanboard\Model\Task;
-use Kanboard\Model\ProjectPermission;
use Kanboard\Notification\Mail;
use Kanboard\Subscriber\NotificationSubscriber;
@@ -24,7 +23,7 @@ class MailTest extends Base
$tc = new TaskCreation($this->container);
$s = new Subtask($this->container);
$c = new Comment($this->container);
- $f = new File($this->container);
+ $f = new TaskFile($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1)));
diff --git a/tests/units/Notification/WebhookTest.php b/tests/units/Notification/WebhookTest.php
index b55cd6e2..7215baeb 100644
--- a/tests/units/Notification/WebhookTest.php
+++ b/tests/units/Notification/WebhookTest.php
@@ -21,92 +21,12 @@ class WebhookTest extends Base
$c->save(array('webhook_url' => 'http://localhost/?task-creation'));
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test')));
-
- $this->assertStringStartsWith('http://localhost/?task-creation&token=', $this->container['httpClient']->getUrl());
-
- $event = $this->container['httpClient']->getData();
- $this->assertNotEmpty($event);
- $this->assertArrayHasKey('event_name', $event);
- $this->assertArrayHasKey('event_data', $event);
- $this->assertEquals('task.create', $event['event_name']);
- $this->assertNotEmpty($event['event_data']);
-
- $this->assertArrayHasKey('project_id', $event['event_data']['task']);
- $this->assertArrayHasKey('id', $event['event_data']['task']);
- $this->assertArrayHasKey('title', $event['event_data']['task']);
- $this->assertArrayHasKey('column_id', $event['event_data']['task']);
- $this->assertArrayHasKey('color_id', $event['event_data']['task']);
- $this->assertArrayHasKey('swimlane_id', $event['event_data']['task']);
- $this->assertArrayHasKey('date_creation', $event['event_data']['task']);
- $this->assertArrayHasKey('date_modification', $event['event_data']['task']);
- $this->assertArrayHasKey('date_moved', $event['event_data']['task']);
- $this->assertArrayHasKey('position', $event['event_data']['task']);
- }
-
- public function testTaskModification()
- {
- $c = new Config($this->container);
- $p = new Project($this->container);
- $tc = new TaskCreation($this->container);
- $tm = new TaskModification($this->container);
- $this->container['dispatcher']->addSubscriber(new NotificationSubscriber($this->container));
-
- $c->save(array('webhook_url' => 'http://localhost/modif/'));
-
- $this->assertEquals(1, $p->create(array('name' => 'test')));
- $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertTrue($tm->update(array('id' => 1, 'title' => 'test update')));
-
- $this->assertStringStartsWith('http://localhost/modif/?token=', $this->container['httpClient']->getUrl());
-
- $event = $this->container['httpClient']->getData();
- $this->assertNotEmpty($event);
- $this->assertArrayHasKey('event_name', $event);
- $this->assertArrayHasKey('event_data', $event);
- $this->assertEquals('task.update', $event['event_name']);
- $this->assertNotEmpty($event['event_data']);
-
- $this->assertArrayHasKey('project_id', $event['event_data']['task']);
- $this->assertArrayHasKey('id', $event['event_data']['task']);
- $this->assertArrayHasKey('title', $event['event_data']['task']);
- $this->assertArrayHasKey('column_id', $event['event_data']['task']);
- $this->assertArrayHasKey('color_id', $event['event_data']['task']);
- $this->assertArrayHasKey('swimlane_id', $event['event_data']['task']);
- $this->assertArrayHasKey('date_creation', $event['event_data']['task']);
- $this->assertArrayHasKey('date_modification', $event['event_data']['task']);
- $this->assertArrayHasKey('date_moved', $event['event_data']['task']);
- $this->assertArrayHasKey('position', $event['event_data']['task']);
- }
-
- public function testCommentCreation()
- {
- $c = new Config($this->container);
- $p = new Project($this->container);
- $tc = new TaskCreation($this->container);
- $cm = new Comment($this->container);
- $this->container['dispatcher']->addSubscriber(new NotificationSubscriber($this->container));
-
- $c->save(array('webhook_url' => 'http://localhost/comment'));
+ $this->container['httpClient']
+ ->expects($this->once())
+ ->method('postJson')
+ ->with($this->stringContains('http://localhost/?task-creation&token='), $this->anything());
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test')));
- $this->assertEquals(1, $cm->create(array('task_id' => 1, 'comment' => 'test comment', 'user_id' => 1)));
-
- $this->assertStringStartsWith('http://localhost/comment?token=', $this->container['httpClient']->getUrl());
-
- $event = $this->container['httpClient']->getData();
- $this->assertNotEmpty($event);
- $this->assertArrayHasKey('event_name', $event);
- $this->assertArrayHasKey('event_data', $event);
- $this->assertEquals('comment.create', $event['event_name']);
- $this->assertNotEmpty($event['event_data']);
-
- $this->assertArrayHasKey('task_id', $event['event_data']['comment']);
- $this->assertArrayHasKey('user_id', $event['event_data']['comment']);
- $this->assertArrayHasKey('comment', $event['event_data']['comment']);
- $this->assertArrayHasKey('id', $event['event_data']['comment']);
- $this->assertEquals('test comment', $event['event_data']['comment']['comment']);
}
}
diff --git a/tests/units/User/DatabaseUserProviderTest.php b/tests/units/User/DatabaseUserProviderTest.php
new file mode 100644
index 00000000..96c03667
--- /dev/null
+++ b/tests/units/User/DatabaseUserProviderTest.php
@@ -0,0 +1,14 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\User\DatabaseUserProvider;
+
+class DatabaseUserProviderTest extends Base
+{
+ public function testGetInternalId()
+ {
+ $provider = new DatabaseUserProvider(array('id' => 123));
+ $this->assertEquals(123, $provider->getInternalId());
+ }
+}
diff --git a/tests/units/Validator/CommentValidatorTest.php b/tests/units/Validator/CommentValidatorTest.php
new file mode 100644
index 00000000..378fe924
--- /dev/null
+++ b/tests/units/Validator/CommentValidatorTest.php
@@ -0,0 +1,57 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\CommentValidator;
+
+class CommentValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new CommentValidator($this->container);
+
+ $result = $validator->validateCreation(array('user_id' => 1, 'task_id' => 1, 'comment' => 'bla'));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateCreation(array('user_id' => 1, 'task_id' => 1, 'comment' => ''));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('user_id' => 1, 'task_id' => 'a', 'comment' => 'bla'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('user_id' => 'b', 'task_id' => 1, 'comment' => 'bla'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('user_id' => 1, 'comment' => 'bla'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('task_id' => 1, 'comment' => 'bla'));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateCreation(array('comment' => 'bla'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array());
+ $this->assertFalse($result[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new CommentValidator($this->container);
+
+ $result = $validator->validateModification(array('id' => 1, 'comment' => 'bla'));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'comment' => ''));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('comment' => 'bla'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('id' => 'b', 'comment' => 'bla'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array());
+ $this->assertFalse($result[0]);
+ }
+}
diff --git a/tests/units/Validator/CurrencyValidatorTest.php b/tests/units/Validator/CurrencyValidatorTest.php
new file mode 100644
index 00000000..39c06d44
--- /dev/null
+++ b/tests/units/Validator/CurrencyValidatorTest.php
@@ -0,0 +1,27 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\CurrencyValidator;
+
+class CurrencyValidatorTest extends Base
+{
+ public function testValidation()
+ {
+ $validator = new CurrencyValidator($this->container);
+ $result = $validator->validateCreation(array());
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('currency' => 'EUR'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('rate' => 1.9));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('currency' => 'EUR', 'rate' => 'foobar'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('currency' => 'EUR', 'rate' => 1.25));
+ $this->assertTrue($result[0]);
+ }
+}
diff --git a/tests/units/Validator/CustomFilterValidatorTest.php b/tests/units/Validator/CustomFilterValidatorTest.php
new file mode 100644
index 00000000..3b70e42c
--- /dev/null
+++ b/tests/units/Validator/CustomFilterValidatorTest.php
@@ -0,0 +1,40 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\CustomFilterValidator;
+
+class CustomFilterValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new CustomFilterValidator($this->container);
+
+ // Validate creation
+ $r = $validator->validateCreation(array('filter' => 'test', 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateCreation(array('filter' => str_repeat('a', 101), 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertFalse($r[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new CustomFilterValidator($this->container);
+
+ $r = $validator->validateModification(array('id' => 1, 'filter' => 'test', 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('filter' => 'test', 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'filter' => str_repeat('a', 101), 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'name' => 'test', 'user_id' => 1, 'project_id' => 1, 'is_shared' => 0));
+ $this->assertFalse($r[0]);
+ }
+}
diff --git a/tests/units/Validator/ExternalLinkValidatorTest.php b/tests/units/Validator/ExternalLinkValidatorTest.php
new file mode 100644
index 00000000..b41b779a
--- /dev/null
+++ b/tests/units/Validator/ExternalLinkValidatorTest.php
@@ -0,0 +1,63 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\ExternalLinkValidator;
+
+class ExternalLinkValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new ExternalLinkValidator($this->container);
+
+ $result = $validator->validateCreation(array('url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateCreation(array('url' => 'http://somewhere', 'task_id' => 'abc', 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('url' => 'http://somewhere', 'task_id' => 1, 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('url' => 'http://somewhere', 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateCreation(array('task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new ExternalLinkValidator($this->container);
+
+ $result = $validator->validateModification(array('id' => 1, 'url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'url' => 'http://somewhere', 'task_id' => 'abc', 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'url' => 'http://somewhere', 'task_id' => 1, 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'url' => 'http://somewhere', 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('id' => 1, 'task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validateModification(array('url' => 'http://somewhere', 'task_id' => 1, 'title' => 'Title', 'link_type' => 'weblink', 'dependency' => 'related'));
+ $this->assertFalse($result[0]);
+ }
+}
diff --git a/tests/units/Validator/GroupValidatorTest.php b/tests/units/Validator/GroupValidatorTest.php
new file mode 100644
index 00000000..879f99ce
--- /dev/null
+++ b/tests/units/Validator/GroupValidatorTest.php
@@ -0,0 +1,30 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\GroupValidator;
+
+class GroupValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new GroupValidator($this->container);
+
+ $result = $validator->validateCreation(array('name' => 'Test'));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateCreation(array('name' => ''));
+ $this->assertFalse($result[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new GroupValidator($this->container);
+
+ $result = $validator->validateModification(array('name' => 'Test', 'id' => 1));
+ $this->assertTrue($result[0]);
+
+ $result = $validator->validateModification(array('name' => 'Test'));
+ $this->assertFalse($result[0]);
+ }
+}
diff --git a/tests/units/Validator/LinkValidatorTest.php b/tests/units/Validator/LinkValidatorTest.php
new file mode 100644
index 00000000..8b7b182c
--- /dev/null
+++ b/tests/units/Validator/LinkValidatorTest.php
@@ -0,0 +1,54 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\LinkValidator;
+
+class LinkValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new LinkValidator($this->container);
+
+ $r = $validator->validateCreation(array('label' => 'a'));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateCreation(array('label' => 'a', 'opposite_label' => 'b'));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateCreation(array('label' => 'relates to'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('label' => 'a', 'opposite_label' => 'a'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('label' => ''));
+ $this->assertFalse($r[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new LinkValidator($this->container);
+
+ $r = $validator->validateModification(array('id' => 20, 'label' => 'a', 'opposite_id' => 0));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('id' => 20, 'label' => 'a', 'opposite_id' => '1'));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('id' => 20, 'label' => 'relates to', 'opposite_id' => '1'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 20, 'label' => '', 'opposite_id' => '1'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('label' => '', 'opposite_id' => '1'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 20, 'opposite_id' => '1'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('label' => 'test'));
+ $this->assertFalse($r[0]);
+ }
+}
diff --git a/tests/units/Validator/PasswordResetValidatorTest.php b/tests/units/Validator/PasswordResetValidatorTest.php
new file mode 100644
index 00000000..4af6c75e
--- /dev/null
+++ b/tests/units/Validator/PasswordResetValidatorTest.php
@@ -0,0 +1,64 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Model\User;
+use Kanboard\Validator\PasswordResetValidator;
+
+class PasswordResetValidatorTest extends Base
+{
+ public function testValidateModification()
+ {
+ $validator = new PasswordResetValidator($this->container);
+ list($valid, ) = $validator->validateModification(array('password' => 'test123', 'confirmation' => 'test123'));
+ $this->assertTrue($valid);
+ }
+
+ public function testValidateModificationWithWrongPasswords()
+ {
+ $validator = new PasswordResetValidator($this->container);
+ list($valid, ) = $validator->validateModification(array('password' => 'test123', 'confirmation' => 'test456'));
+ $this->assertFalse($valid);
+ }
+
+ public function testValidateModificationWithPasswordTooShort()
+ {
+ $validator = new PasswordResetValidator($this->container);
+ list($valid, ) = $validator->validateModification(array('password' => 'test', 'confirmation' => 'test'));
+ $this->assertFalse($valid);
+ }
+
+ public function testValidateCreation()
+ {
+ $this->container['sessionStorage']->captcha = 'test';
+
+ $validator = new PasswordResetValidator($this->container);
+ list($valid,) = $validator->validateCreation(array('username' => 'foobar', 'captcha' => 'test'));
+ $this->assertTrue($valid);
+ }
+
+ public function testValidateCreationWithNoUsername()
+ {
+ $this->container['sessionStorage']->captcha = 'test';
+
+ $validator = new PasswordResetValidator($this->container);
+ list($valid,) = $validator->validateCreation(array('captcha' => 'test'));
+ $this->assertFalse($valid);
+ }
+
+ public function testValidateCreationWithWrongCaptcha()
+ {
+ $this->container['sessionStorage']->captcha = 'test123';
+
+ $validator = new PasswordResetValidator($this->container);
+ list($valid,) = $validator->validateCreation(array('username' => 'foobar', 'captcha' => 'test'));
+ $this->assertFalse($valid);
+ }
+
+ public function testValidateCreationWithMissingCaptcha()
+ {
+ $validator = new PasswordResetValidator($this->container);
+ list($valid,) = $validator->validateCreation(array('username' => 'foobar', 'captcha' => 'test'));
+ $this->assertFalse($valid);
+ }
+}
diff --git a/tests/units/Validator/ProjectValidatorTest.php b/tests/units/Validator/ProjectValidatorTest.php
new file mode 100644
index 00000000..a73e8247
--- /dev/null
+++ b/tests/units/Validator/ProjectValidatorTest.php
@@ -0,0 +1,67 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\ProjectValidator;
+use Kanboard\Model\Project;
+
+class ProjectValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new ProjectValidator($this->container);
+ $p = new Project($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'UnitTest1', 'identifier' => 'test1')));
+ $this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
+
+ $project = $p->getById(1);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('TEST1', $project['identifier']);
+
+ $project = $p->getById(2);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('', $project['identifier']);
+
+ $r = $validator->validateCreation(array('name' => 'test', 'identifier' => 'TEST1'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('name' => 'test', 'identifier' => 'test1'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('name' => 'test', 'identifier' => 'a-b-c'));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('name' => 'test', 'identifier' => 'test 123'));
+ $this->assertFalse($r[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new ProjectValidator($this->container);
+ $p = new Project($this->container);
+
+ $this->assertEquals(1, $p->create(array('name' => 'UnitTest1', 'identifier' => 'test1')));
+ $this->assertEquals(2, $p->create(array('name' => 'UnitTest2', 'identifier' => 'TEST2')));
+
+ $project = $p->getById(1);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('TEST1', $project['identifier']);
+
+ $project = $p->getById(2);
+ $this->assertNotEmpty($project);
+ $this->assertEquals('TEST2', $project['identifier']);
+
+ $r = $validator->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'TEST1'));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'test3'));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => ''));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'TEST2'));
+ $this->assertFalse($r[0]);
+ }
+}
diff --git a/tests/units/Validator/TaskLinkValidatorTest.php b/tests/units/Validator/TaskLinkValidatorTest.php
new file mode 100644
index 00000000..5ac4588e
--- /dev/null
+++ b/tests/units/Validator/TaskLinkValidatorTest.php
@@ -0,0 +1,72 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\TaskLinkValidator;
+use Kanboard\Model\TaskLink;
+use Kanboard\Model\TaskCreation;
+use Kanboard\Model\Project;
+
+class TaskLinkValidatorTest extends Base
+{
+ public function testValidateCreation()
+ {
+ $validator = new TaskLinkValidator($this->container);
+ $tl = new TaskLink($this->container);
+ $p = new Project($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' => 'A')));
+ $this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'B')));
+
+ $links = $tl->getAll(1);
+ $this->assertEmpty($links);
+
+ $links = $tl->getAll(2);
+ $this->assertEmpty($links);
+
+ // Check creation
+ $r = $validator->validateCreation(array('task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 2));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateCreation(array('task_id' => 1, 'link_id' => 1));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('task_id' => 1, 'opposite_task_id' => 2));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('task_id' => 1, 'opposite_task_id' => 2));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateCreation(array('task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 1));
+ $this->assertFalse($r[0]);
+ }
+
+ public function testValidateModification()
+ {
+ $validator = new TaskLinkValidator($this->container);
+ $p = new Project($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' => 'A')));
+ $this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'B')));
+
+ // Check modification
+ $r = $validator->validateModification(array('id' => 1, 'task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 2));
+ $this->assertTrue($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'task_id' => 1, 'link_id' => 1));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'task_id' => 1, 'opposite_task_id' => 2));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'task_id' => 1, 'opposite_task_id' => 2));
+ $this->assertFalse($r[0]);
+
+ $r = $validator->validateModification(array('id' => 1, 'task_id' => 1, 'link_id' => 1, 'opposite_task_id' => 1));
+ $this->assertFalse($r[0]);
+ }
+}
diff --git a/tests/units/Validator/UserValidatorTest.php b/tests/units/Validator/UserValidatorTest.php
new file mode 100644
index 00000000..6d904ade
--- /dev/null
+++ b/tests/units/Validator/UserValidatorTest.php
@@ -0,0 +1,41 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Kanboard\Validator\UserValidator;
+use Kanboard\Core\Security\Role;
+
+class UserValidatorTest extends Base
+{
+ public function testValidatePasswordModification()
+ {
+ $validator = new UserValidator($this->container);
+
+ $this->container['sessionStorage']->user = array(
+ 'id' => 1,
+ 'role' => Role::APP_ADMIN,
+ 'username' => 'admin',
+ );
+
+ $result = $validator->validatePasswordModification(array());
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validatePasswordModification(array('id' => 1));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validatePasswordModification(array('id' => 1, 'password' => '123456'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validatePasswordModification(array('id' => 1, 'password' => '123456', 'confirmation' => 'wrong'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validatePasswordModification(array('id' => 1, 'password' => '123456', 'confirmation' => '123456'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validatePasswordModification(array('id' => 1, 'password' => '123456', 'confirmation' => '123456', 'current_password' => 'wrong'));
+ $this->assertFalse($result[0]);
+
+ $result = $validator->validatePasswordModification(array('id' => 1, 'password' => '123456', 'confirmation' => '123456', 'current_password' => 'admin'));
+ $this->assertTrue($result[0]);
+ }
+}
diff --git a/tests/units/fixtures/bitbucket_comment_created.json b/tests/units/fixtures/bitbucket_comment_created.json
deleted file mode 100644
index 66a09bb8..00000000
--- a/tests/units/fixtures/bitbucket_comment_created.json
+++ /dev/null
@@ -1,147 +0,0 @@
-{
- "actor": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "repository": {
- "full_name": "minicoders/test-webhook",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "type": "repository",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- }
- },
- "owner": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "name": "test-webhook"
- },
- "comment": {
- "content": {
- "html": "<ol>\n<li>step1</li>\n<li>step2</li>\n</ol>",
- "raw": "1. step1\n2. step2",
- "markup": "markdown"
- },
- "created_on": "2015-06-21T02:51:40.532529+00:00",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1#comment-19176252"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments/19176252"
- }
- },
- "id": 19176252,
- "user": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "updated_on": null
- },
- "issue": {
- "updated_on": "2015-06-21T02:51:40.536516+00:00",
- "votes": 0,
- "assignee": null,
- "links": {
- "vote": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/vote"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1"
- },
- "watch": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/watch"
- },
- "comments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments"
- },
- "attachments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/attachments"
- }
- },
- "priority": "major",
- "kind": "bug",
- "watches": 1,
- "edited_on": null,
- "state": "new",
- "content": {
- "html": "<p><strong>test</strong></p>",
- "raw": "**test**",
- "markup": "markdown"
- },
- "component": null,
- "milestone": null,
- "version": null,
- "id": 1,
- "created_on": "2015-06-21T02:17:40.990654+00:00",
- "type": "issue",
- "reporter": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "title": "My new issue"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/bitbucket_issue_assigned.json b/tests/units/fixtures/bitbucket_issue_assigned.json
deleted file mode 100644
index 0324afb2..00000000
--- a/tests/units/fixtures/bitbucket_issue_assigned.json
+++ /dev/null
@@ -1,209 +0,0 @@
-{
- "repository": {
- "name": "test-webhook",
- "type": "repository",
- "full_name": "minicoders/test-webhook",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- }
- },
- "owner": {
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- }
- },
- "changes": {
- "responsible": {
- "new": {
- "is_system": false,
- "name": "Frederic Guillot",
- "username": "minicoders",
- "id": 1290132,
- "billing_external_uuid": null
- },
- "old": null
- },
- "assignee": {
- "new": {
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "old": null
- }
- },
- "actor": {
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "issue": {
- "repository": {
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- }
- },
- "type": "repository",
- "full_name": "minicoders/test-webhook",
- "name": "test-webhook",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}"
- },
- "edited_on": null,
- "component": null,
- "updated_on": "2015-06-21T15:21:28.023525+00:00",
- "reporter": {
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "state": "open",
- "content": {
- "raw": "**test**",
- "markup": "markdown",
- "html": "<p><strong>test</strong></p>"
- },
- "kind": "bug",
- "links": {
- "attachments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/attachments"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue"
- },
- "watch": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/watch"
- },
- "vote": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/vote"
- },
- "comments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1"
- }
- },
- "created_on": "2015-06-21T02:17:40.990654+00:00",
- "watches": 1,
- "milestone": null,
- "version": null,
- "assignee": {
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "title": "My new issue",
- "priority": "major",
- "id": 1,
- "votes": 0,
- "type": "issue"
- },
- "comment": {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments/19181255"
- }
- },
- "updated_on": null,
- "content": {
- "raw": null,
- "markup": "markdown",
- "html": ""
- },
- "id": 19181255,
- "created_on": "2015-06-21T15:21:28.043980+00:00",
- "user": {
- "display_name": "Frederic Guillot",
- "type": "user",
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- }
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/bitbucket_issue_closed.json b/tests/units/fixtures/bitbucket_issue_closed.json
deleted file mode 100644
index 473b5167..00000000
--- a/tests/units/fixtures/bitbucket_issue_closed.json
+++ /dev/null
@@ -1,183 +0,0 @@
-{
- "actor": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user",
- "display_name": "Frederic Guillot",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "changes": {
- "status": {
- "old": "new",
- "new": "closed"
- }
- },
- "repository": {
- "full_name": "minicoders/test-webhook",
- "type": "repository",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- }
- },
- "owner": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user",
- "display_name": "Frederic Guillot",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "name": "test-webhook"
- },
- "comment": {
- "content": {
- "html": "",
- "raw": null,
- "markup": "markdown"
- },
- "created_on": "2015-06-21T02:54:40.263014+00:00",
- "updated_on": null,
- "id": 19176265,
- "user": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user",
- "display_name": "Frederic Guillot",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments/19176265"
- }
- }
- },
- "issue": {
- "state": "closed",
- "votes": 0,
- "assignee": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user",
- "display_name": "Frederic Guillot",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "links": {
- "vote": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/vote"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1"
- },
- "watch": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/watch"
- },
- "comments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments"
- },
- "attachments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/attachments"
- }
- },
- "priority": "major",
- "version": null,
- "watches": 1,
- "edited_on": null,
- "reporter": {
- "username": "minicoders",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user",
- "display_name": "Frederic Guillot",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- }
- },
- "content": {
- "html": "<p><strong>test</strong></p>",
- "raw": "**test**",
- "markup": "markdown"
- },
- "component": null,
- "created_on": "2015-06-21T02:17:40.990654+00:00",
- "updated_on": "2015-06-21T02:54:40.249466+00:00",
- "kind": "bug",
- "id": 1,
- "milestone": null,
- "type": "issue",
- "repository": {
- "full_name": "minicoders/test-webhook",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "type": "repository",
- "name": "test-webhook",
- "links": {
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- }
- }
- },
- "title": "My new issue"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/bitbucket_issue_opened.json b/tests/units/fixtures/bitbucket_issue_opened.json
deleted file mode 100644
index 7891c230..00000000
--- a/tests/units/fixtures/bitbucket_issue_opened.json
+++ /dev/null
@@ -1,112 +0,0 @@
-{
- "issue": {
- "type": "issue",
- "links": {
- "attachments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/attachments"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue"
- },
- "comments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments"
- },
- "vote": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/vote"
- },
- "watch": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/watch"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1"
- }
- },
- "component": null,
- "updated_on": "2015-06-21T02:17:40.990654+00:00",
- "reporter": {
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "type": "user",
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "watches": 1,
- "kind": "bug",
- "edited_on": null,
- "created_on": "2015-06-21T02:17:40.990654+00:00",
- "milestone": null,
- "version": null,
- "state": "new",
- "assignee": null,
- "content": {
- "raw": "**test**",
- "markup": "markdown",
- "html": "<p><strong>test</strong></p>"
- },
- "priority": "major",
- "id": 1,
- "votes": 0,
- "title": "My new issue"
- },
- "repository": {
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- }
- },
- "type": "repository",
- "full_name": "minicoders/test-webhook",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "name": "test-webhook",
- "owner": {
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "type": "user",
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- }
- },
- "actor": {
- "links": {
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- }
- },
- "username": "minicoders",
- "type": "user",
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/bitbucket_issue_reopened.json b/tests/units/fixtures/bitbucket_issue_reopened.json
deleted file mode 100644
index bb950916..00000000
--- a/tests/units/fixtures/bitbucket_issue_reopened.json
+++ /dev/null
@@ -1,183 +0,0 @@
-{
- "issue": {
- "edited_on": null,
- "watches": 1,
- "created_on": "2015-06-21T02:17:40.990654+00:00",
- "reporter": {
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user"
- },
- "content": {
- "markup": "markdown",
- "raw": "**test**",
- "html": "<p><strong>test</strong></p>"
- },
- "id": 1,
- "milestone": null,
- "repository": {
- "full_name": "minicoders/test-webhook",
- "type": "repository",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- }
- },
- "name": "test-webhook"
- },
- "component": null,
- "version": null,
- "votes": 0,
- "priority": "major",
- "type": "issue",
- "state": "open",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1"
- },
- "comments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue"
- },
- "watch": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/watch"
- },
- "attachments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/attachments"
- },
- "vote": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/vote"
- }
- },
- "kind": "bug",
- "updated_on": "2015-06-21T14:56:49.739063+00:00",
- "assignee": {
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user"
- },
- "title": "My new issue"
- },
- "comment": {
- "id": 19181022,
- "created_on": "2015-06-21T14:56:49.749362+00:00",
- "content": {
- "markup": "markdown",
- "raw": null,
- "html": ""
- },
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments/19181022"
- }
- },
- "user": {
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user"
- },
- "updated_on": null
- },
- "repository": {
- "name": "test-webhook",
- "owner": {
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user"
- },
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "type": "repository",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- }
- },
- "full_name": "minicoders/test-webhook"
- },
- "changes": {
- "status": {
- "new": "open",
- "old": "closed"
- }
- },
- "actor": {
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "display_name": "Frederic Guillot",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "type": "user"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/bitbucket_issue_unassigned.json b/tests/units/fixtures/bitbucket_issue_unassigned.json
deleted file mode 100644
index 3cbab2ea..00000000
--- a/tests/units/fixtures/bitbucket_issue_unassigned.json
+++ /dev/null
@@ -1,193 +0,0 @@
-{
- "comment": {
- "updated_on": null,
- "content": {
- "html": "",
- "markup": "markdown",
- "raw": null
- },
- "created_on": "2015-06-21T15:07:45.787623+00:00",
- "user": {
- "display_name": "Frederic Guillot",
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments/19181143"
- }
- },
- "id": 19181143
- },
- "issue": {
- "state": "open",
- "content": {
- "html": "<p><strong>test</strong></p>",
- "markup": "markdown",
- "raw": "**test**"
- },
- "milestone": null,
- "type": "issue",
- "version": null,
- "title": "My new issue",
- "assignee": null,
- "kind": "bug",
- "component": null,
- "priority": "major",
- "reporter": {
- "display_name": "Frederic Guillot",
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "created_on": "2015-06-21T02:17:40.990654+00:00",
- "edited_on": null,
- "updated_on": "2015-06-21T15:07:45.775705+00:00",
- "id": 1,
- "votes": 0,
- "repository": {
- "full_name": "minicoders/test-webhook",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- }
- },
- "type": "repository",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "name": "test-webhook"
- },
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1"
- },
- "watch": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/watch"
- },
- "vote": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/vote"
- },
- "comments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/comments"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/issue/1/my-new-issue"
- },
- "attachments": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/issues/1/attachments"
- }
- },
- "watches": 1
- },
- "actor": {
- "display_name": "Frederic Guillot",
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "repository": {
- "full_name": "minicoders/test-webhook",
- "owner": {
- "display_name": "Frederic Guillot",
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "type": "repository",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- }
- },
- "name": "test-webhook",
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}"
- },
- "changes": {
- "responsible": {
- "old": {
- "is_system": false,
- "username": "minicoders",
- "name": "Frederic Guillot",
- "billing_external_uuid": null,
- "id": 1290132
- },
- "new": null
- },
- "assignee": {
- "old": {
- "display_name": "Frederic Guillot",
- "username": "minicoders",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}"
- },
- "new": null
- }
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/bitbucket_push.json b/tests/units/fixtures/bitbucket_push.json
deleted file mode 100644
index f480b074..00000000
--- a/tests/units/fixtures/bitbucket_push.json
+++ /dev/null
@@ -1,182 +0,0 @@
-{
- "push": {
- "changes": [
- {
- "forced": false,
- "old": {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/refs/branches/master"
- },
- "commits": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commits/master"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/branch/master"
- }
- },
- "name": "master",
- "target": {
- "date": "2015-06-21T00:50:37+00:00",
- "hash": "b6b46580eb9b20a06396f5f697ea1a55cf170e69",
- "message": "test edited online with Bitbucket for task #5",
- "type": "commit",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commit/b6b46580eb9b20a06396f5f697ea1a55cf170e69"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/commits/b6b46580eb9b20a06396f5f697ea1a55cf170e69"
- }
- },
- "parents": [
- {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commit/7251db4b505cbfca3f845ebcff0ec0ddc4003ed8"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/commits/7251db4b505cbfca3f845ebcff0ec0ddc4003ed8"
- }
- },
- "type": "commit",
- "hash": "7251db4b505cbfca3f845ebcff0ec0ddc4003ed8"
- }
- ],
- "author": {
- "raw": "Frederic Guillot <bob>",
- "user": {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "username": "minicoders",
- "display_name": "Frederic Guillot"
- }
- }
- },
- "type": "branch"
- },
- "created": false,
- "links": {
- "diff": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/diff/824059cce7667d3f8d8780cc707391be821e0ea6..b6b46580eb9b20a06396f5f697ea1a55cf170e69"
- },
- "commits": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commits?include=824059cce7667d3f8d8780cc707391be821e0ea6exclude=b6b46580eb9b20a06396f5f697ea1a55cf170e69"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/branches/compare/824059cce7667d3f8d8780cc707391be821e0ea6..b6b46580eb9b20a06396f5f697ea1a55cf170e69"
- }
- },
- "new": {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/refs/branches/master"
- },
- "commits": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commits/master"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/branch/master"
- }
- },
- "name": "master",
- "target": {
- "date": "2015-06-21T03:15:08+00:00",
- "hash": "824059cce7667d3f8d8780cc707391be821e0ea6",
- "message": "Test another commit #2\n",
- "type": "commit",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commit/824059cce7667d3f8d8780cc707391be821e0ea6"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/commits/824059cce7667d3f8d8780cc707391be821e0ea6"
- }
- },
- "parents": [
- {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook/commit/24aa9d82bbb6f9a60f743fe538deb0a44622fc98"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook/commits/24aa9d82bbb6f9a60f743fe538deb0a44622fc98"
- }
- },
- "type": "commit",
- "hash": "24aa9d82bbb6f9a60f743fe538deb0a44622fc98"
- }
- ],
- "author": {
- "raw": "Frederic Guillot <bob@localhost>"
- }
- },
- "type": "branch"
- },
- "closed": false
- }
- ]
- },
- "repository": {
- "name": "test-webhook",
- "owner": {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "username": "minicoders",
- "display_name": "Frederic Guillot"
- },
- "uuid": "{590fd9c4-0812-425e-8d72-ab08b4fd5735}",
- "type": "repository",
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/repositories/minicoders/test-webhook"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders/test-webhook"
- },
- "avatar": {
- "href": "https://bitbucket.org/minicoders/test-webhook/avatar/16/"
- }
- },
- "full_name": "minicoders/test-webhook"
- },
- "actor": {
- "links": {
- "self": {
- "href": "https://bitbucket.org/api/2.0/users/minicoders"
- },
- "html": {
- "href": "https://bitbucket.org/minicoders"
- },
- "avatar": {
- "href": "https://bitbucket.org/account/minicoders/avatar/32/"
- }
- },
- "type": "user",
- "uuid": "{fc59b45a-f68b-4fc1-ad1f-a17d4f17cd2c}",
- "username": "minicoders",
- "display_name": "Frederic Guillot"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_comment_created.json b/tests/units/fixtures/github_comment_created.json
deleted file mode 100644
index bb601c7c..00000000
--- a/tests/units/fixtures/github_comment_created.json
+++ /dev/null
@@ -1,187 +0,0 @@
-{
- "action": "created",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "open",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 1,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:45:51Z",
- "closed_at": null,
- "body": "plop"
- },
- "comment": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments/113834672",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3#issuecomment-113834672",
- "issue_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "id": 113834672,
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "created_at": "2015-06-20T22:45:51Z",
- "updated_at": "2015-06-20T22:45:51Z",
- "body": "test"
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_assigned.json b/tests/units/fixtures/github_issue_assigned.json
deleted file mode 100644
index 9125e976..00000000
--- a/tests/units/fixtures/github_issue_assigned.json
+++ /dev/null
@@ -1,196 +0,0 @@
-{
- "action": "assigned",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "open",
- "locked": false,
- "assignee": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:12:15Z",
- "closed_at": null,
- "body": "plop"
- },
- "assignee": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_closed.json b/tests/units/fixtures/github_issue_closed.json
deleted file mode 100644
index 5d2393eb..00000000
--- a/tests/units/fixtures/github_issue_closed.json
+++ /dev/null
@@ -1,159 +0,0 @@
-{
- "action": "closed",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "closed",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:28:32Z",
- "closed_at": "2015-06-20T22:28:32Z",
- "body": "plop"
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 2,
- "forks": 0,
- "open_issues": 2,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_labeled.json b/tests/units/fixtures/github_issue_labeled.json
deleted file mode 100644
index 4576b04f..00000000
--- a/tests/units/fixtures/github_issue_labeled.json
+++ /dev/null
@@ -1,170 +0,0 @@
-{
- "action": "labeled",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [
- {
- "url": "https://api.github.com/repos/kanboardapp/webhook/labels/bug",
- "name": "bug",
- "color": "fc2929"
- }
- ],
- "state": "open",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:37:49Z",
- "closed_at": null,
- "body": "plop"
- },
- "label": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/labels/bug",
- "name": "bug",
- "color": "fc2929"
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_opened.json b/tests/units/fixtures/github_issue_opened.json
deleted file mode 100644
index 13f8d930..00000000
--- a/tests/units/fixtures/github_issue_opened.json
+++ /dev/null
@@ -1,159 +0,0 @@
-{
- "action": "opened",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "Test Webhook",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "open",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T21:58:20Z",
- "closed_at": null,
- "body": "plop"
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_reopened.json b/tests/units/fixtures/github_issue_reopened.json
deleted file mode 100644
index 70f9e884..00000000
--- a/tests/units/fixtures/github_issue_reopened.json
+++ /dev/null
@@ -1,159 +0,0 @@
-{
- "action": "reopened",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "open",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:33:35Z",
- "closed_at": null,
- "body": "plop"
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_unassigned.json b/tests/units/fixtures/github_issue_unassigned.json
deleted file mode 100644
index 0a578bab..00000000
--- a/tests/units/fixtures/github_issue_unassigned.json
+++ /dev/null
@@ -1,178 +0,0 @@
-{
- "action": "unassigned",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "open",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:26:05Z",
- "closed_at": null,
- "body": "plop"
- },
- "assignee": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_issue_unlabeled.json b/tests/units/fixtures/github_issue_unlabeled.json
deleted file mode 100644
index 47070a91..00000000
--- a/tests/units/fixtures/github_issue_unlabeled.json
+++ /dev/null
@@ -1,164 +0,0 @@
-{
- "action": "unlabeled",
- "issue": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/issues/3",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/labels{/name}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/comments",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/3/events",
- "html_url": "https://github.com/kanboardapp/webhook/issues/3",
- "id": 89823399,
- "number": 3,
- "title": "test with ngrok",
- "user": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- },
- "labels": [],
- "state": "open",
- "locked": false,
- "assignee": null,
- "milestone": null,
- "comments": 0,
- "created_at": "2015-06-20T21:58:20Z",
- "updated_at": "2015-06-20T22:43:48Z",
- "closed_at": null,
- "body": "plop"
- },
- "label": {
- "url": "https://api.github.com/repos/kanboardapp/webhook/labels/bug",
- "name": "bug",
- "color": "fc2929"
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "login": "kanboardapp",
- "id": 8738076,
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/kanboardapp",
- "html_url": "https://github.com/kanboardapp",
- "followers_url": "https://api.github.com/users/kanboardapp/followers",
- "following_url": "https://api.github.com/users/kanboardapp/following{/other_user}",
- "gists_url": "https://api.github.com/users/kanboardapp/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/kanboardapp/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/kanboardapp/subscriptions",
- "organizations_url": "https://api.github.com/users/kanboardapp/orgs",
- "repos_url": "https://api.github.com/users/kanboardapp/repos",
- "events_url": "https://api.github.com/users/kanboardapp/events{/privacy}",
- "received_events_url": "https://api.github.com/users/kanboardapp/received_events",
- "type": "Organization",
- "site_admin": false
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://api.github.com/repos/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": "2014-10-25T20:10:38Z",
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": "2014-10-25T22:02:01Z",
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/github_push.json b/tests/units/fixtures/github_push.json
deleted file mode 100644
index 5ae9b766..00000000
--- a/tests/units/fixtures/github_push.json
+++ /dev/null
@@ -1,165 +0,0 @@
-{
- "ref": "refs/heads/master",
- "before": "895598f1baf1ee5fb3f43ebc509aa86e263bde4b",
- "after": "98dee3e49ee7aa66ffec1f761af93da5ffd711f6",
- "created": false,
- "deleted": false,
- "forced": false,
- "base_ref": null,
- "compare": "https://github.com/kanboardapp/webhook/compare/895598f1baf1...98dee3e49ee7",
- "commits": [
- {
- "id": "98dee3e49ee7aa66ffec1f761af93da5ffd711f6",
- "distinct": true,
- "message": "Update README to fix #1",
- "timestamp": "2015-06-20T18:54:52-04:00",
- "url": "https://github.com/kanboardapp/webhook/commit/98dee3e49ee7aa66ffec1f761af93da5ffd711f6",
- "author": {
- "name": "Frédéric Guillot",
- "email": "fred@kanboard.net",
- "username": "fguillot"
- },
- "committer": {
- "name": "Frédéric Guillot",
- "email": "fred@kanboard.net",
- "username": "fguillot"
- },
- "added": [],
- "removed": [],
- "modified": [
- "README.md"
- ]
- }
- ],
- "head_commit": {
- "id": "98dee3e49ee7aa66ffec1f761af93da5ffd711f6",
- "distinct": true,
- "message": "Update README",
- "timestamp": "2015-06-20T18:54:52-04:00",
- "url": "https://github.com/kanboardapp/webhook/commit/98dee3e49ee7aa66ffec1f761af93da5ffd711f6",
- "author": {
- "name": "Frédéric Guillot",
- "email": "fred@kanboard.net",
- "username": "fguillot"
- },
- "committer": {
- "name": "Frédéric Guillot",
- "email": "fred@kanboard.net",
- "username": "fguillot"
- },
- "added": [],
- "removed": [],
- "modified": [
- "README.md"
- ]
- },
- "repository": {
- "id": 25744941,
- "name": "webhook",
- "full_name": "kanboardapp/webhook",
- "owner": {
- "name": "kanboardapp",
- "email": null
- },
- "private": false,
- "html_url": "https://github.com/kanboardapp/webhook",
- "description": "",
- "fork": false,
- "url": "https://github.com/kanboardapp/webhook",
- "forks_url": "https://api.github.com/repos/kanboardapp/webhook/forks",
- "keys_url": "https://api.github.com/repos/kanboardapp/webhook/keys{/key_id}",
- "collaborators_url": "https://api.github.com/repos/kanboardapp/webhook/collaborators{/collaborator}",
- "teams_url": "https://api.github.com/repos/kanboardapp/webhook/teams",
- "hooks_url": "https://api.github.com/repos/kanboardapp/webhook/hooks",
- "issue_events_url": "https://api.github.com/repos/kanboardapp/webhook/issues/events{/number}",
- "events_url": "https://api.github.com/repos/kanboardapp/webhook/events",
- "assignees_url": "https://api.github.com/repos/kanboardapp/webhook/assignees{/user}",
- "branches_url": "https://api.github.com/repos/kanboardapp/webhook/branches{/branch}",
- "tags_url": "https://api.github.com/repos/kanboardapp/webhook/tags",
- "blobs_url": "https://api.github.com/repos/kanboardapp/webhook/git/blobs{/sha}",
- "git_tags_url": "https://api.github.com/repos/kanboardapp/webhook/git/tags{/sha}",
- "git_refs_url": "https://api.github.com/repos/kanboardapp/webhook/git/refs{/sha}",
- "trees_url": "https://api.github.com/repos/kanboardapp/webhook/git/trees{/sha}",
- "statuses_url": "https://api.github.com/repos/kanboardapp/webhook/statuses/{sha}",
- "languages_url": "https://api.github.com/repos/kanboardapp/webhook/languages",
- "stargazers_url": "https://api.github.com/repos/kanboardapp/webhook/stargazers",
- "contributors_url": "https://api.github.com/repos/kanboardapp/webhook/contributors",
- "subscribers_url": "https://api.github.com/repos/kanboardapp/webhook/subscribers",
- "subscription_url": "https://api.github.com/repos/kanboardapp/webhook/subscription",
- "commits_url": "https://api.github.com/repos/kanboardapp/webhook/commits{/sha}",
- "git_commits_url": "https://api.github.com/repos/kanboardapp/webhook/git/commits{/sha}",
- "comments_url": "https://api.github.com/repos/kanboardapp/webhook/comments{/number}",
- "issue_comment_url": "https://api.github.com/repos/kanboardapp/webhook/issues/comments{/number}",
- "contents_url": "https://api.github.com/repos/kanboardapp/webhook/contents/{+path}",
- "compare_url": "https://api.github.com/repos/kanboardapp/webhook/compare/{base}...{head}",
- "merges_url": "https://api.github.com/repos/kanboardapp/webhook/merges",
- "archive_url": "https://api.github.com/repos/kanboardapp/webhook/{archive_format}{/ref}",
- "downloads_url": "https://api.github.com/repos/kanboardapp/webhook/downloads",
- "issues_url": "https://api.github.com/repos/kanboardapp/webhook/issues{/number}",
- "pulls_url": "https://api.github.com/repos/kanboardapp/webhook/pulls{/number}",
- "milestones_url": "https://api.github.com/repos/kanboardapp/webhook/milestones{/number}",
- "notifications_url": "https://api.github.com/repos/kanboardapp/webhook/notifications{?since,all,participating}",
- "labels_url": "https://api.github.com/repos/kanboardapp/webhook/labels{/name}",
- "releases_url": "https://api.github.com/repos/kanboardapp/webhook/releases{/id}",
- "created_at": 1414267838,
- "updated_at": "2014-10-25T20:10:38Z",
- "pushed_at": 1434840893,
- "git_url": "git://github.com/kanboardapp/webhook.git",
- "ssh_url": "git@github.com:kanboardapp/webhook.git",
- "clone_url": "https://github.com/kanboardapp/webhook.git",
- "svn_url": "https://github.com/kanboardapp/webhook",
- "homepage": null,
- "size": 124,
- "stargazers_count": 0,
- "watchers_count": 0,
- "language": null,
- "has_issues": true,
- "has_downloads": true,
- "has_wiki": true,
- "has_pages": false,
- "forks_count": 0,
- "mirror_url": null,
- "open_issues_count": 3,
- "forks": 0,
- "open_issues": 3,
- "watchers": 0,
- "default_branch": "master",
- "stargazers": 0,
- "master_branch": "master",
- "organization": "kanboardapp"
- },
- "pusher": {
- "name": "fguillot",
- "email": "fred@kanboard.net"
- },
- "organization": {
- "login": "kanboardapp",
- "id": 8738076,
- "url": "https://api.github.com/orgs/kanboardapp",
- "repos_url": "https://api.github.com/orgs/kanboardapp/repos",
- "events_url": "https://api.github.com/orgs/kanboardapp/events",
- "members_url": "https://api.github.com/orgs/kanboardapp/members{/member}",
- "public_members_url": "https://api.github.com/orgs/kanboardapp/public_members{/member}",
- "avatar_url": "https://avatars.githubusercontent.com/u/8738076?v=3",
- "description": null
- },
- "sender": {
- "login": "fguillot",
- "id": 323546,
- "avatar_url": "https://avatars.githubusercontent.com/u/323546?v=3",
- "gravatar_id": "",
- "url": "https://api.github.com/users/fguillot",
- "html_url": "https://github.com/fguillot",
- "followers_url": "https://api.github.com/users/fguillot/followers",
- "following_url": "https://api.github.com/users/fguillot/following{/other_user}",
- "gists_url": "https://api.github.com/users/fguillot/gists{/gist_id}",
- "starred_url": "https://api.github.com/users/fguillot/starred{/owner}{/repo}",
- "subscriptions_url": "https://api.github.com/users/fguillot/subscriptions",
- "organizations_url": "https://api.github.com/users/fguillot/orgs",
- "repos_url": "https://api.github.com/users/fguillot/repos",
- "events_url": "https://api.github.com/users/fguillot/events{/privacy}",
- "received_events_url": "https://api.github.com/users/fguillot/received_events",
- "type": "User",
- "site_admin": false
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/gitlab_comment_created.json b/tests/units/fixtures/gitlab_comment_created.json
deleted file mode 100644
index b6599419..00000000
--- a/tests/units/fixtures/gitlab_comment_created.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
- "object_kind": "note",
- "user": {
- "name": "Fred",
- "username": "minicoders",
- "avatar_url": "https://secure.gravatar.com/avatar/3c44936e5a56f80711bff14987d2733f?s=40&d=identicon"
- },
- "project_id": 320820,
- "repository": {
- "name": "test-webhook",
- "url": "git@gitlab.com:minicoders/test-webhook.git",
- "description": "",
- "homepage": "https://gitlab.com/minicoders/test-webhook"
- },
- "object_attributes": {
- "id": 1642761,
- "note": "Super comment!",
- "noteable_type": "Issue",
- "author_id": 74067,
- "created_at": "2015-07-17 21:37:48 UTC",
- "updated_at": "2015-07-17 21:37:48 UTC",
- "project_id": 320820,
- "attachment": null,
- "line_code": null,
- "commit_id": "",
- "noteable_id": 355691,
- "st_diff": null,
- "system": false,
- "url": "https://gitlab.com/minicoders/test-webhook/issues/1#note_1642761"
- },
- "issue": {
- "id": 355691,
- "title": "Bug",
- "assignee_id": null,
- "author_id": 74067,
- "project_id": 320820,
- "created_at": "2015-07-17 21:31:47 UTC",
- "updated_at": "2015-07-17 21:37:48 UTC",
- "position": 0,
- "branch_name": null,
- "description": "There is a bug somewhere.\r\n\r\nBye",
- "milestone_id": null,
- "state": "opened",
- "iid": 1
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/gitlab_issue_closed.json b/tests/units/fixtures/gitlab_issue_closed.json
deleted file mode 100644
index 82500b3c..00000000
--- a/tests/units/fixtures/gitlab_issue_closed.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "object_kind": "issue",
- "user": {
- "name": "Fred",
- "username": "minicoders",
- "avatar_url": "https://secure.gravatar.com/avatar/3c44936e5a56f80711bff14987d2733f?s=40&d=identicon"
- },
- "object_attributes": {
- "id": 355691,
- "title": "Bug",
- "assignee_id": null,
- "author_id": 74067,
- "project_id": 320820,
- "created_at": "2015-07-17 21:31:47 UTC",
- "updated_at": "2015-07-17 22:10:17 UTC",
- "position": 0,
- "branch_name": null,
- "description": "There is a bug somewhere.\r\n\r\nBye",
- "milestone_id": null,
- "state": "closed",
- "iid": 1,
- "url": "https://gitlab.com/minicoders/test-webhook/issues/1",
- "action": "close"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/gitlab_issue_opened.json b/tests/units/fixtures/gitlab_issue_opened.json
deleted file mode 100644
index 3e75c138..00000000
--- a/tests/units/fixtures/gitlab_issue_opened.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "object_kind": "issue",
- "user": {
- "name": "Fred",
- "username": "minicoders",
- "avatar_url": "https://secure.gravatar.com/avatar/3c44936e5a56f80711bff14987d2733f?s=40&d=identicon"
- },
- "object_attributes": {
- "id": 355691,
- "title": "Bug",
- "assignee_id": null,
- "author_id": 74067,
- "project_id": 320820,
- "created_at": "2015-07-17 21:31:47 UTC",
- "updated_at": "2015-07-17 21:31:47 UTC",
- "position": 0,
- "branch_name": null,
- "description": "There is a bug somewhere.\r\n\r\nBye",
- "milestone_id": null,
- "state": "opened",
- "iid": 1,
- "url": "https://gitlab.com/minicoders/test-webhook/issues/1",
- "action": "open"
- }
-} \ No newline at end of file
diff --git a/tests/units/fixtures/gitlab_push.json b/tests/units/fixtures/gitlab_push.json
deleted file mode 100644
index ed77f041..00000000
--- a/tests/units/fixtures/gitlab_push.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "object_kind": "push",
- "before": "e4ec6156d208a45fc546fae73c28300b5af1692a",
- "after": "48aafa75eef9ad253aa254b0c82c987a52ebea78",
- "ref": "refs/heads/master",
- "checkout_sha": "48aafa75eef9ad253aa254b0c82c987a52ebea78",
- "message": null,
- "user_id": 74067,
- "user_name": "Fred",
- "user_email": "f+gitlab@minicoders.com",
- "project_id": 320820,
- "repository": {
- "name": "test-webhook",
- "url": "git@gitlab.com:minicoders/test-webhook.git",
- "description": "",
- "homepage": "https://gitlab.com/minicoders/test-webhook",
- "git_http_url": "https://gitlab.com/minicoders/test-webhook.git",
- "git_ssh_url": "git@gitlab.com:minicoders/test-webhook.git",
- "visibility_level": 0
- },
- "commits": [
- {
- "id": "48aafa75eef9ad253aa254b0c82c987a52ebea78",
- "message": "Fix bug #2",
- "timestamp": "2015-06-21T00:41:41+00:00",
- "url": "https://gitlab.com/minicoders/test-webhook/commit/48aafa75eef9ad253aa254b0c82c987a52ebea78",
- "author": {
- "name": "Fred",
- "email": "me@localhost"
- }
- },
- {
- "id": "e4ec6156d208a45fc546fae73c28300b5af1692a",
- "message": "test",
- "timestamp": "2015-06-21T00:35:55+00:00",
- "url": "https://gitlab.com/localhost/test-webhook/commit/e4ec6156d208a45fc546fae73c28300b5af1692a",
- "author": {
- "name": "Fred",
- "email": "me@localhost"
- }
- }
- ],
- "total_commits_count": 2
-} \ No newline at end of file