diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d13467a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +.gitattributes export-ignore +.gitignore export-ignore +/config.php export-ignore +/.github export-ignore +/.vscode export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5e7e548f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: [push, pull_request] + +jobs: + ci: + runs-on: ubuntu-20.04 + + strategy: + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} + + services: + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: phproject + ports: + - 3306/tcp + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: phpunit:8.5 + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@v3 + + - uses: actions/cache@v3 + env: + cache-name: cache-composer + with: + path: ~/.composer + key: ${{ runner.os }}-ci-${{ env.cache-name }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-ci-${{ env.cache-name }}- + ${{ runner.os }}-ci- + ${{ runner.os }}- + + - name: Syntax check + run: if find . -name "*.php" ! -path "./vendor/*" -exec php -l {} 2>&1 \; | grep "syntax error, unexpected"; then exit 1; fi + + - name: Composer + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - run: vendor/bin/phpcs + + - name: Install Phproject + run: php install.php --site-name=Test --site-url=http://localhost/ --timezone=America/Phoenix --admin-username=test --admin-email=test@example.com --admin-password=secret --db-host=127.0.0.1 --db-port=${{ job.services.mysql.ports['3306'] }} --db-user=root diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9273eecb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release Assets + +on: + push: + tags: + - v1.** + +jobs: + publish-assets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install dependencies + run: | + curl -o composer.phar -L https://getcomposer.org/composer-stable.phar + php composer.phar install --no-ansi --no-interaction --no-dev + + - name: Clean up project + run: | + rm -rf .git* .vscode + rm -f composer.phar + + - name: Build archive + run: zip -r /tmp/phproject-${{ github.ref_name }}.zip "$GITHUB_WORKSPACE" + + - name: Upload archive + uses: softprops/action-gh-release@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + files: | + /tmp/phproject-${{ github.ref_name }}.zip diff --git a/.gitignore b/.gitignore index 3a95d3ac..3687945c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ plugin config.ini +config.php +/vendor/ +/phpunit +*.cache diff --git a/.htaccess b/.htaccess index c2d253bb..b2c9493f 100644 --- a/.htaccess +++ b/.htaccess @@ -7,15 +7,8 @@ RewriteEngine On # # RewriteBase / -RewriteCond %{REQUEST_URI} app\/(controller|dict|helper|model|view) -RewriteRule app\/(controller|dict|helper|model|view) - [R=404] - -RewriteCond %{REQUEST_URI} \.ini$ -RewriteRule \.ini$ - [R=404] - -RewriteCond %{REQUEST_URI} log\/[a-zA-Z1-9]+\.log -RewriteRule log\/[a-zA-Z1-9]+\.log - [R=404] - +RewriteRule ^app\/(controller|dict|helper|model|view) - [R=404] +RewriteRule ^(tmp|log)\/|\.ini$ - [R=404] RewriteCond %{REQUEST_FILENAME} !-l RewriteCond %{REQUEST_FILENAME} !-f diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 00000000..6515261c --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,30 @@ + + + + app/ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a73ba767..00000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: php -php: - - '5.4' - - '5.5' - - '5.6' - - '7.0' - - hhvm - - nightly -before_script: - - if find . -name "*.php" ! -path "./lib/*" -exec php -l {} 2>&1 \; | grep "syntax error, unexpected"; then exit 1; fi diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..bf08864f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "bmewburn.vscode-intelephense-client", + "xdebug.php-debug", + "shevaua.phpcs" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9a7e1130 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "phpcs.standard": "PSR12" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7d7f5875 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing to Phproject + +## Issues + +Found a bug, or want to request a new feature? Great! Just make sure there isn't an existing issue before creating a new one, and try to provide as much detail as possible. + +## Pull Requests + +Thanks for contributing! We have a few basic guidelines for helping us accept your pull requests. + +1. New translations should be added on [Crowdin](https://crowdin.com/project/phproject) rather than through a pull request. +2. Code style should follow the [PSR-12 standard](https://www.php-fig.org/psr/psr-12/). +3. New features may be best implemented through [a plugin](https://www.phproject.org/plugins.html) rather than into the core code. diff --git a/README.md b/README.md index b398858a..79b79f57 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,19 @@ -[![Gitter Chat](https://img.shields.io/badge/Gitter-Join%20Chat-3498DB.svg)](https://gitter.im/Alanaktion/phproject?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) -[![Codacy Badge](https://api.codacy.com/project/badge/grade/2e382a33465448868ca2c0d4b1c937db)](https://www.codacy.com/app/alanaktion/phproject) -[![Build Status](https://api.travis-ci.org/Alanaktion/phproject.svg)](https://travis-ci.org/Alanaktion/phproject) -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/phproject/localized.png)](https://crowdin.com/project/phproject) +[![CI](https://github.com/Alanaktion/phproject/workflows/CI/badge.svg)](https://github.com/Alanaktion/phproject/actions?query=workflow%3ACI) +[![Crowdin](https://badges.crowdin.net/phproject/localized.svg)](https://crowdin.com/project/phproject) Phproject -=========== +========= *A high-performance project management system in PHP* +[![20i FOSS Awards](https://i.imgur.com/KkovAqB.png)](https://www.20i.com/foss-awards/category/project-management) + ### Installation -Simply clone the repo into a web accessible directory, go to the page in a browser, and fill in your database connection details. +Download and extract [the latest release](https://github.com/Alanaktion/phproject/releases/latest) a web accessible directory, go to the page in a browser, and fill in your database connection details. Detailed requirements and installation instructions are available at [phproject.org](http://www.phproject.org/install.html). +### Development +Phproject uses [Composer](https://getcomposer.org/) for dependency management. After cloning the repository, run `composer install` to install the required packages. + ### Contributing -Phproject is maintained as an open source project for use by anyone around the world under the [GNU General Public License](http://www.gnu.org/licenses/gpl-3.0.txt). If you find a bug or would like a new feature added, [open an issue](https://github.com/Alanaktion/phproject/issues/new) or [submit a pull request](https://github.com/Alanaktion/phproject/compare/) with new code. +Phproject is maintained as an open source project for use by anyone around the world under the [GNU General Public License](http://www.gnu.org/licenses/gpl-3.0.txt). If you find a bug or would like a new feature added, [open an issue](https://github.com/Alanaktion/phproject/issues/new) or [submit a pull request](https://github.com/Alanaktion/phproject/compare/) with new code. If you want to help with translation, [you can submit translations via Crowdin](https://crowdin.com/project/phproject). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..d19ea627 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +Since our team is small, and we don't update often, we only support the latest release of Phproject. It is highly recommended that you update whenever we release a new version, as it likely includes major bug fixes, and can impact the security of your site. + +## Reporting a Vulnerability + +If you find an issue with the security of Phproject, you may report the vulnerability on [huntr.dev](https://huntr.dev/bounties/disclose), or let me know personally via email at alan@phpizza.com. If emailing, I recommend encrypting your communication with [my PGP public key](https://keybase.io/alanaktion/pgp_keys.asc). + +I'll do my best to get back to you within 48 hours, at least with an idea of when we can fix the issue. Major issues that involve significant changes to the application can take several weeks since no one works on this project full time, but we'll do what we can to fix things. + +If it's been more than a week and we haven't replied, or if we have replied but it's been a while and we haven't communicated or fixed the issue you found, let us know and we'll invite you to a private fork where we can collaborate on a fix. diff --git a/app/controller.php b/app/controller.php index e5b09369..850fc756 100644 --- a/app/controller.php +++ b/app/controller.php @@ -1,93 +1,102 @@ get("user.id")) { + if ($f3->get("user.rank") >= $rank) { + return $id; + } else { + $f3->error(403); + return false; + } + } else { + if ($f3->get("site.demo") && is_numeric($f3->get("site.demo"))) { + $user = new \Model\User(); + $user->load($f3->get("site.demo")); + if ($user->id) { + $session = new \Model\Session($user->id); + $session->setCurrent(); + $f3->set("user", $user->cast()); + $f3->set("user_obj", $user); + return; + } else { + $f3->set("error", "Auto-login failed, demo user was not found."); + } + } + if (empty($_GET)) { + $f3->reroute("/login?to=" . urlencode($f3->get("PATH"))); + } else { + $f3->reroute("/login?to=" . urlencode($f3->get("PATH")) . urlencode("?" . http_build_query($_GET))); + } + return false; + } + } - /** - * Require a user to be logged in. Redirects to /login if a session is not found. - * @param int $rank - * @return int|bool - */ - protected function _requireLogin($rank = \Model\User::RANK_CLIENT) { - $f3 = \Base::instance(); - if($id = $f3->get("user.id")) { - if($f3->get("user.rank") >= $rank) { - return $id; - } else { - $f3->error(403); - $f3->unload(); - return false; - } - } else { - if($f3->get("site.demo") && is_numeric($f3->get("site.demo"))) { - $user = new \Model\User(); - $user->load($f3->get("site.demo")); - if($user->id) { - $session = new \Model\Session($user->id); - $session->setCurrent(); - $f3->set("user", $user->cast()); - $f3->set("user_obj", $user); - return; - } else { - $f3->set("error", "Auto-login failed, demo user was not found."); - } - } - if(empty($_GET)) { - $f3->reroute("/login?to=" . urlencode($f3->get("PATH"))); - } else { - $f3->reroute("/login?to=" . urlencode($f3->get("PATH")) . urlencode("?" . http_build_query($_GET))); - } - $f3->unload(); - return false; - } - } + /** + * Require a user to be an administrator. Throws HTTP 403 if logged in, but not an admin. + * @param int $rank + * @return int|bool + */ + protected function _requireAdmin($rank = \Model\User::RANK_ADMIN) + { + $id = $this->_requireLogin(); - /** - * Require a user to be an administrator. Throws HTTP 403 if logged in, but not an admin. - * @param int $rank - * @return int|bool - */ - protected function _requireAdmin($rank = \Model\User::RANK_ADMIN) { - $id = $this->_requireLogin(); + $f3 = \Base::instance(); + if ($f3->get("user.role") == "admin" && $f3->get("user.rank") >= $rank) { + return $id; + } else { + $f3->error(403); + return false; + } + } - $f3 = \Base::instance(); - if($f3->get("user.role") == "admin" && $f3->get("user.rank") >= $rank) { - return $id; - } else { - $f3->error(403); - $f3->unload(); - return false; - } - } + /** + * Render a view + * @param string $file + * @param string $mime + * @param array $hive + * @param integer $ttl + */ + protected function _render($file, $mime = "text/html", array $hive = null, $ttl = 0) + { + echo \Helper\View::instance()->render($file, $mime, $hive, $ttl); + } - /** - * Render a view - * @param string $file - * @param string $mime - * @param array $hive - * @param integer $ttl - */ - protected function _render($file, $mime = "text/html", array $hive = null, $ttl = 0) { - echo \Helper\View::instance()->render($file, $mime, $hive, $ttl); - } + /** + * Output object as JSON and set appropriate headers + * @param mixed $object + */ + protected function _printJson($object) + { + if (!headers_sent()) { + header("Content-type: application/json"); + } + echo json_encode($object, JSON_THROW_ON_ERROR); + } - /** - * Output object as JSON and set appropriate headers - * @param mixed $object - */ - protected function _printJson($object) { - if(!headers_sent()) { - header("Content-type: application/json"); - } - echo json_encode($object); - } - - /** - * Get current time and date in a MySQL NOW() format - * @param boolean $time Whether to include the time in the string - * @return string - */ - function now($time = true) { - return $time ? date("Y-m-d H:i:s") : date("Y-m-d"); - } + /** + * Get current time and date in a MySQL NOW() format + * @param boolean $time Whether to include the time in the string + * @return string + */ + public function now($time = true) + { + return $time ? date("Y-m-d H:i:s") : date("Y-m-d"); + } + /** + * Validate the request's CSRF token, exiting if invalid + */ + protected function validateCsrf() + { + \Helper\Security::instance()->validateCsrfToken(); + } } diff --git a/app/controller/admin.php b/app/controller/admin.php index f3346945..09d96a35 100644 --- a/app/controller/admin.php +++ b/app/controller/admin.php @@ -2,583 +2,708 @@ namespace Controller; -class Admin extends \Controller { - - protected $_userId; - - public function __construct() { - $this->_userId = $this->_requireAdmin(); - \Base::instance()->set("menuitem", "admin"); - } - - /** - * @param \Base $f3 - */ - public function index($f3) { - $f3->set("title", $f3->get("dict.administration")); - $f3->set("menuitem", "admin"); - - if($f3->get("POST.action") == "clearcache") { - \Cache::instance()->reset(); - $f3->set("success", "Cache cleared successfully."); - } - - $db = $f3->get("db.instance"); - - // Gather some stats - $result = $db->exec("SELECT COUNT(id) AS `count` FROM user WHERE deleted_date IS NULL AND role != 'group'"); - $f3->set("count_user", $result[0]["count"]); - $result = $db->exec("SELECT COUNT(id) AS `count` FROM issue WHERE deleted_date IS NULL"); - $f3->set("count_issue", $result[0]["count"]); - $result = $db->exec("SELECT COUNT(id) AS `count` FROM issue_update"); - $f3->set("count_issue_update", $result[0]["count"]); - $result = $db->exec("SELECT COUNT(id) AS `count` FROM issue_comment"); - $f3->set("count_issue_comment", $result[0]["count"]); - $result = $db->exec("SELECT value as version FROM config WHERE attribute = 'version'"); - $f3->set("version", $result[0]["version"]); - - if($f3->get("CACHE") == "apc") { - $f3->set("apc_stats", apc_cache_info("user", true)); - } - - $this->_render("admin/index.html"); - } - - /** - * @param \Base $f3 - */ - public function config($f3) { - $status = new \Model\Issue\Status; - $f3->set("issue_statuses", $status->find()); - - $f3->set("title", $f3->get("dict.configuration")); - $this->_render("admin/config.html"); - } - - /** - * @param \Base $f3 - * @throws \Exception - */ - public function config_post_saveattribute($f3) { - $attribute = str_replace("-", ".", $f3->get("POST.attribute")); - $value = $f3->get("POST.value"); - $response = array("error" => null); - - if(!$attribute) { - $response["error"] = "No attribute specified."; - $this->_printJson($response); - return; - } - - $config = new \Model\Config; - $config->load(array("attribute = ?", $attribute)); - - $config->attribute = $attribute; - switch($attribute) { - case "site-name": - if(trim($value)) { - $config->value = $value; - $config->save(); - } else { - $response["error"] = "Site name cannot be empty."; - } - break; - case "site-timezone": - if(in_array($value, timezone_identifiers_list())) { - $config->value = $value; - $config->save(); - } else { - $response["error"] = "Timezone is invalid."; - } - break; - default: - $config->value = $value; - $config->save(); - } - - if(!$response["error"]) { - $response["attribute"] = $attribute; - $response["value"] = $value; - } - - $this->_printJson($response); - } - - /** - * @param \Base $f3 - */ - public function plugins($f3) { - $f3->set("title", $f3->get("dict.plugins")); - $this->_render("admin/plugins.html"); - } - - /** - * @param \Base $f3 - * @param array $params - */ - public function plugin_single($f3, $params) { - $this->_userId = $this->_requireAdmin(5); - $plugins = $f3->get("plugins"); - if($plugin = $plugins[$params["id"]]) { - $f3->set("plugin", $plugin); - if($f3->get("AJAX")) { - $plugin->_admin(); - } else { - $f3->set("title", $plugin->_package()); - $this->_render("admin/plugins/single.html"); - } - } else { - $f3->error(404); - } - } - - /** - * @param \Base $f3 - */ - public function users($f3) { - $f3->set("title", $f3->get("dict.users")); - - $users = new \Model\User(); - $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'")); - $f3->set("select_users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - - $this->_render("admin/users.html"); - } - - /** - * @param \Base $f3 - */ - public function deleted_users($f3) { - $f3->set("title", $f3->get("dict.users")); - - $users = new \Model\User(); - $f3->set("users", $users->find("deleted_date IS NOT NULL AND role != 'group'")); - - $this->_render("admin/users/deleted.html"); - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function user_edit($f3, $params) { - $f3->set("title", $f3->get("dict.edit_user")); - - $user = new \Model\User(); - $user->load($params["id"]); - - if($user->id) { - if($user->rank > $f3->get("user.rank")) { - $f3->error(403, "You are not authorized to edit this user."); - return; - } - $f3->set("this_user", $user); - $this->_render("admin/users/edit.html"); - } else { - $f3->error(404, "User does not exist."); - } - - } - - /** - * @param \Base $f3 - */ - public function user_new($f3) { - $f3->set("title", $f3->get("dict.new_user")); - $f3->set("rand_color", sprintf("#%02X%02X%02X", mt_rand(0, 0xFF), mt_rand(0, 0xFF), mt_rand(0, 0xFF))); - $this->_render("admin/users/edit.html"); - } - - /** - * @param \Base $f3 - * @throws \Exception - */ - public function user_save($f3) { - $security = \Helper\Security::instance(); - $user = new \Model\User; - - // Load current user if set, otherwise validate fields for new user - if($user_id = $f3->get("POST.user_id")) { - $f3->set("title", $f3->get("dict.edit_user")); - $user->load($user_id); - $f3->set("this_user", $user); - } else { - $f3->set("title", $f3->get("dict.new_user")); - - // Verify a password is being set - if(!$f3->get("POST.password")) { - $f3->set("error", "User already exists with this username"); - $this->_render("admin/users/edit.html"); - return; - } - - // Check for existing users with same info - $user->load(array("username = ?", $f3->get("POST.username"))); - if($user->id) { - $f3->set("error", "User already exists with this username"); - $this->_render("admin/users/edit.html"); - return; - } - - $user->load(array("email = ?", $f3->get("POST.email"))); - if($user->id) { - $f3->set("error", "User already exists with this email address"); - $this->_render("admin/users/edit.html"); - return; - } - - // Set new user fields - $user->api_key = $security->salt_sha1(); - $user->created_date = $this->now(); - } - - // Validate password if being set - if($f3->get("POST.password")) { - if($f3->get("POST.password") != $f3->get("POST.password_confirm")) { - $f3->set("error", "Passwords do not match"); - $this->_render("admin/users/edit.html"); - return; - } - $min = $f3->get("security.min_pass_len"); - if(strlen($f3->get("POST.password")) < $min) { - $f3->set("error", "Passwords must be at least {$min} characters"); - $this->_render("admin/users/edit.html"); - return; - } - - // Check if giving user temporary or permanent password - if($f3->get("POST.temporary_password")) { - $user->salt = null; - $user->password = $security->hash($f3->get("POST.password"), ""); - } else { - $user->salt = $security->salt(); - $user->password = $security->hash($f3->get("POST.password"), $user->salt); - } - } - - // Set basic fields - $user->username = $f3->get("POST.username"); - $user->email = $f3->get("POST.email"); - $user->name = $f3->get("POST.name"); - if($user->id != $f3->get("user.id")) { - // Don't allow user to change own rank - $user->rank = $f3->get("POST.rank"); - } - $user->role = $user->rank < \Model\User::RANK_ADMIN ? 'user' : 'admin'; - $user->task_color = ltrim($f3->get("POST.task_color"), "#"); - - // Save user - $user->save(); - $f3->reroute("/admin/users#" . $user->id); - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function user_delete($f3, $params) { - $user = new \Model\User(); - $user->load($params["id"]); - if(!$user->id) { - $f3->reroute("/admin/users"); - return; - } - - // Reassign issues if requested - if($f3->get("POST.reassign")) { - switch ($f3->get("POST.reassign")) { - case "unassign": - $user->reassignIssues(null); - break; - case "to-user": - $user->reassignIssues($f3->get("POST.reassign-to")); - break; - } - } - - $user->delete(); - if($f3->get("AJAX")) { - $this->_printJson(array("deleted" => 1)); - } else { - $f3->reroute("/admin/users"); - } - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function user_undelete($f3, $params) { - $user = new \Model\User(); - $user->load($params["id"]); - if(!$user->id) { - $f3->reroute("/admin/users"); - return; - } - - $user->deleted_date = null; - $user->save(); - - if($f3->get("AJAX")) { - $this->_printJson(array("deleted" => 1)); - } else { - $f3->reroute("/admin/users"); - } - } - - /** - * @param \Base $f3 - */ - public function groups($f3) { - $f3->set("title", $f3->get("dict.groups")); - - $group = new \Model\User(); - $groups = $group->find("deleted_date IS NULL AND role = 'group'"); - - $group_array = array(); - $db = $f3->get("db.instance"); - foreach($groups as $g) { - $db->exec("SELECT g.id FROM user_group g JOIN user u ON g.user_id = u.id WHERE g.group_id = ? AND u.deleted_date IS NULL", $g["id"]); - $count = $db->count(); - $group_array[] = array( - "id" => $g["id"], - "name" => $g["name"], - "task_color" => $g["task_color"], - "count" => $count - ); - } - $f3->set("groups", $group_array); - - $this->_render("admin/groups.html"); - } - - /** - * @param \Base $f3 - */ - public function group_new($f3) { - $f3->set("title", $f3->get("dict.groups")); - - if($f3->get("POST")) { - $group = new \Model\User(); - $group->name = $f3->get("POST.name"); - $group->username = \Web::instance()->slug($group->name); - $group->role = "group"; - $group->task_color = sprintf("%02X%02X%02X", mt_rand(0, 0xFF), mt_rand(0, 0xFF), mt_rand(0, 0xFF)); - $group->created_date = $this->now(); - $group->save(); - $f3->reroute("/admin/groups"); - } else { - $f3->error(405); - } - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function group_edit($f3, $params) { - $f3->set("title", $f3->get("dict.groups")); - - $group = new \Model\User(); - $group->load(array("id = ? AND deleted_date IS NULL AND role = 'group'", $params["id"])); - $f3->set("group", $group); - - $members = new \Model\Custom("user_group_user"); - $f3->set("members", $members->find(array("group_id = ? AND deleted_date IS NULL", $group->id))); - - $users = new \Model\User(); - $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - - $this->_render("admin/groups/edit.html"); - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function group_delete($f3, $params) { - $group = new \Model\User(); - $group->load($params["id"]); - $group->delete(); - if($f3->get("AJAX")) { - $this->_printJson(array("deleted" => 1) + $group->cast()); - } else { - $f3->reroute("/admin/groups"); - } - } - - /** - * @param \Base $f3 - * @throws \Exception - */ - public function group_ajax($f3) { - if(!$f3->get("AJAX")) { - $f3->error(400); - } - - $group = new \Model\User(); - $group->load(array("id = ? AND deleted_date IS NULL AND role = 'group'", $f3->get("POST.group_id"))); - - if(!$group->id) { - $f3->error(404); - return; - } - - switch($f3->get('POST.action')) { - case "add_member": - foreach($f3->get("POST.user") as $user_id) { - $user_group = new \Model\User\Group(); - $user_group->load(array("user_id = ? AND group_id = ?", $user_id, $f3->get("POST.group_id"))); - if(!$user_group->id) { - $user_group->group_id = $f3->get("POST.group_id"); - $user_group->user_id = $user_id; - $user_group->save(); - } else { - // user already in group - } - } - break; - case "remove_member": - $user_group = new \Model\User\Group(); - $user_group->load(array("user_id = ? AND group_id = ?", $f3->get("POST.user_id"), $f3->get("POST.group_id"))); - $user_group->delete(); - $this->_printJson(array("deleted" => 1)); - break; - case "change_title": - $group->name = trim($f3->get("POST.name")); - $group->username = \Web::instance()->slug($group->name); - $group->save(); - $this->_printJson(array("changed" => 1)); - break; - case "change_task_color": - $group->task_color = ltrim($f3->get("POST.value"), '#'); - $group->save(); - $this->_printJson(array("changed" => 1)); - break; - case "change_api_visibility": - $group->api_visible = (int)!!$f3->get("POST.value"); - $group->save(); - $this->_printJson(array("changed" => 1)); - break; - } - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function group_setmanager($f3, $params) { - $db = $f3->get("db.instance"); - - $group = new \Model\User(); - $group->load(array("id = ? AND deleted_date IS NULL AND role = 'group'", $params["id"])); - - if(!$group->id) { - $f3->error(404); - return; - } - - // Remove Manager status from all members and set manager status on specified user - $db->exec("UPDATE user_group SET manager = 0 WHERE group_id = ?", $group->id); - $db->exec("UPDATE user_group SET manager = 1 WHERE id = ?", $params["user_group_id"]); - - $f3->reroute("/admin/groups/" . $group->id); - } - - /** - * @param \Base $f3 - */ - public function sprints($f3) { - $f3->set("title", $f3->get("dict.sprints")); - - $sprints = new \Model\Sprint(); - $f3->set("sprints", $sprints->find()); - - $this->_render("admin/sprints.html"); - } - - /** - * @param \Base $f3 - */ - public function sprint_new($f3) { - $f3->set("title", $f3->get("dict.sprints")); - - if($post = $f3->get("POST")) { - if(empty($post["start_date"]) || empty($post["end_date"])) { - $f3->set("error", "Start and end date are required"); - $this->_render("admin/sprints/new.html"); - return; - } - - $start = strtotime($post["start_date"]); - $end = strtotime($post["end_date"]); - - if($end <= $start) { - $f3->set("error", "End date must be after start date"); - $this->_render("admin/sprints/new.html"); - return; - } - - $sprint = new \Model\Sprint(); - $sprint->name = trim($post["name"]); - $sprint->start_date = date("Y-m-d", $start); - $sprint->end_date = date("Y-m-d", $end); - $sprint->save(); - $f3->reroute("/admin/sprints"); - return; - } - - $this->_render("admin/sprints/new.html"); - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function sprint_edit($f3, $params) { - $f3->set("title", $f3->get("dict.sprints")); - - $sprint = new \Model\Sprint; - $sprint->load($params["id"]); - if(!$sprint->id) { - $f3->error(404); - return; - } - - if($post = $f3->get("POST")) { - if(empty($post["start_date"]) || empty($post["end_date"])) { - $f3->set("error", "Start and end date are required"); - $this->_render("admin/sprints/edit.html"); - return; - } - - $start = strtotime($post["start_date"]); - $end = strtotime($post["end_date"]); - - if($end <= $start) { - $f3->set("error", "End date must be after start date"); - $this->_render("admin/sprints/edit.html"); - return; - } - - $sprint->name = trim($post["name"]); - $sprint->start_date = date("Y-m-d", $start); - $sprint->end_date = date("Y-m-d", $end); - $sprint->save(); - - $f3->reroute("/admin/sprints"); - return; - } - $f3->set("sprint", $sprint); - - $this->_render("admin/sprints/edit.html"); - } - +class Admin extends \Controller +{ + protected $_userId; + + public function __construct() + { + $this->_userId = $this->_requireAdmin(); + \Base::instance()->set("menuitem", "admin"); + } + + /** + * GET /admin + * @param \Base $f3 + */ + public function index(\Base $f3) + { + $f3->set("title", $f3->get("dict.administration")); + $f3->set("menuitem", "admin"); + + if ($f3->get("POST.action") == "clearcache") { + $this->validateCsrf(); + + $cache = \Cache::instance(); + + // Clear configured cache + $cache->reset(); + + // Clear filesystem cache (thumbnails, etc.) + $cache->load("folder=tmp/cache/"); + $cache->reset(); + + // Reset cache configuration + $cache->load($f3->get("CACHE")); + + $f3->set("success", "Cache cleared successfully."); + } + + $db = $f3->get("db.instance"); + + // Gather some stats + $result = $db->exec("SELECT COUNT(id) AS `count` FROM user WHERE deleted_date IS NULL AND role != 'group'"); + $f3->set("count_user", $result[0]["count"]); + $result = $db->exec("SELECT COUNT(id) AS `count` FROM issue WHERE deleted_date IS NULL"); + $f3->set("count_issue", $result[0]["count"]); + $result = $db->exec("SELECT COUNT(id) AS `count` FROM issue_comment"); + $f3->set("count_issue_comment", $result[0]["count"]); + $result = $db->exec("SELECT value as version FROM config WHERE attribute = 'version'"); + $f3->set("version", $result[0]["version"]); + + if ($f3->get("CACHE") == "apc" && function_exists("apc_cache_info")) { + $f3->set("apc_stats", apc_cache_info("user", true)); + } + + $this->_render("admin/index.html"); + } + + /** + * GET /admin/release.json + * + * Check for a new release and report some basic stats + * + * @return void + */ + public function releaseCheck() + { + // Set user agent to identify this instance + $context = stream_context_create([ + 'http' => [ + 'header' => 'User-Agent: Alanaktion/phproject', + ], + ]); + try { + $result = file_get_contents('https://api.github.com/repos/Alanaktion/phproject/releases/latest', false, $context); + $release = json_decode($result, false, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->_printJson(['error' => 1]); + return; + } + + $latest = ltrim($release->tag_name, 'v'); + if (!version_compare($latest, PHPROJECT_VERSION, '>')) { + $this->_printJson(['update_available' => false]); + return; + } + + if (!headers_sent()) { + header('Content-Type: application/json'); + header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 3600 * 12) . ' GMT'); + } + $return = [ + 'update_available' => true, + 'name' => $release->tag_name, + 'description' => $release->body, + 'url' => $release->html_url, + ]; + if (!empty($release->body)) { + // Render markdown description as HTML + $parsedown = new \Parsedown(); + $parsedown->setUrlsLinked(false); + $parsedown->setMarkupEscaped(true); + $return['description_html'] = $parsedown->text($release->body); + } + echo json_encode($return, JSON_THROW_ON_ERROR); + } + + /** + * GET /admin/config + * @param \Base $f3 + */ + public function config(\Base $f3) + { + $this->_requireAdmin(\Model\User::RANK_SUPER); + + $status = new \Model\Issue\Status(); + $f3->set("issue_statuses", $status->find()); + + $f3->set("title", $f3->get("dict.configuration")); + $this->_render("admin/config.html"); + } + + /** + * POST /admin/config/saveattribute + * @param \Base $f3 + * @throws \Exception + */ + public function config_post_saveattribute(\Base $f3) + { + $this->validateCsrf(); + $this->_requireAdmin(\Model\User::RANK_SUPER); + + $attribute = str_replace("-", ".", $f3->get("POST.attribute")); + $value = $f3->get("POST.value"); + $response = ["error" => null]; + + if (!$attribute) { + $response["error"] = "No attribute specified."; + $this->_printJson($response); + return; + } + + $config = new \Model\Config(); + $config->load(["attribute = ?", $attribute]); + + $config->attribute = $attribute; + switch ($attribute) { + case "site-name": + if (trim($value)) { + $config->value = $value; + $config->save(); + } else { + $response["error"] = "Site name cannot be empty."; + } + break; + case "site-timezone": + if (in_array($value, timezone_identifiers_list())) { + $config->value = $value; + $config->save(); + } else { + $response["error"] = "Timezone is invalid."; + } + break; + default: + $config->value = $value; + $config->save(); + } + + if (!$response["error"]) { + $response["attribute"] = $attribute; + $response["value"] = $value; + } + + $this->_printJson($response); + } + + /** + * GET /admin/plugins + * @param \Base $f3 + */ + public function plugins(\Base $f3) + { + $f3->set("title", $f3->get("dict.plugins")); + $this->_render("admin/plugins.html"); + } + + /** + * GET /admin/plugins/@id + * @param \Base $f3 + * @param array $params + */ + public function plugin_single(\Base $f3, array $params) + { + $this->_requireAdmin(\Model\User::RANK_SUPER); + + $plugins = $f3->get("plugins"); + if ($plugin = $plugins[$params["id"]]) { + $f3->set("plugin", $plugin); + if ($f3->get("AJAX")) { + $plugin->_admin(); + } else { + $f3->set("title", $plugin->_package()); + $this->_render("admin/plugins/single.html"); + } + } else { + $f3->error(404); + } + } + + /** + * GET /users + * @param \Base $f3 + */ + public function users(\Base $f3) + { + $f3->set("title", $f3->get("dict.users")); + + $users = new \Model\User(); + $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'")); + $f3->set("select_users", $users->find("deleted_date IS NULL AND role != 'group'", ["order" => "name ASC"])); + + $this->_render("admin/users.html"); + } + + /** + * GET /admin/users/deleted + * @param \Base $f3 + */ + public function deleted_users(\Base $f3) + { + $f3->set("title", $f3->get("dict.users")); + + $users = new \Model\User(); + $f3->set("users", $users->find("deleted_date IS NOT NULL AND role != 'group'")); + + $this->_render("admin/users/deleted.html"); + } + + /** + * GET /admin/users/@id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function user_edit(\Base $f3, array $params) + { + $f3->set("title", $f3->get("dict.edit_user")); + + $user = new \Model\User(); + $user->load($params["id"]); + + if ($user->id) { + if ($user->rank > $f3->get("user.rank")) { + $f3->error(403, "You are not authorized to edit this user."); + return; + } + $f3->set("this_user", $user); + $this->_render("admin/users/edit.html"); + } else { + $f3->error(404, "User does not exist."); + } + } + + /** + * GET /admin/users/new + * @param \Base $f3 + */ + public function user_new(\Base $f3) + { + $f3->set("title", $f3->get("dict.new_user")); + $f3->set("rand_color", sprintf("#%02X%02X%02X", random_int(0, 0xFF), random_int(0, 0xFF), random_int(0, 0xFF))); + $this->_render("admin/users/edit.html"); + } + + /** + * POST /admin/users, POST /admin/users/@id + * @param \Base $f3 + * @throws \Exception + */ + public function user_save(\Base $f3) + { + $this->validateCsrf(); + $security = \Helper\Security::instance(); + $user = new \Model\User(); + $user_id = $f3->get("POST.user_id"); + + try { + // Check for existing users with same info + $user->load(["username = ? AND id != ?", $f3->get("POST.username"), $user_id]); + if ($user->id) { + throw new \Exception("Another user already exists with this username"); + } + $user->load(["email = ? AND id != ?", $f3->get("POST.email"), $user_id]); + if ($user->id) { + throw new \Exception("Another user already exists with this email address"); + } + + if ($user_id) { + $f3->set("title", $f3->get("dict.edit_user")); + $user->load($user_id); + $f3->set("this_user", $user); + } else { + $f3->set("title", $f3->get("dict.new_user")); + + // Verify a password is being set + if (!$f3->get("POST.password")) { + throw new \Exception("A password is required for a new user"); + } + + // Set new user fields + $user->api_key = $security->salt_sha1(); + $user->created_date = $this->now(); + } + + // Validate password if being set + if ($f3->get("POST.password")) { + if ($f3->get("POST.password") != $f3->get("POST.password_confirm")) { + throw new \Exception("Passwords do not match"); + } + $min = $f3->get("security.min_pass_len"); + if (strlen($f3->get("POST.password")) < $min) { + throw new \Exception("Passwords must be at least {$min} characters"); + } + + // Check if giving user temporary or permanent password + if ($f3->get("POST.temporary_password")) { + $user->salt = null; + $user->password = $security->hash($f3->get("POST.password"), ""); + } else { + $user->salt = $security->salt(); + $user->password = $security->hash($f3->get("POST.password"), $user->salt); + } + } + + if (!$f3->get("POST.name")) { + throw new \Exception("Please enter a name."); + } + if (!preg_match("/#?[0-9a-f]{3,6}/i", $f3->get("POST.task_color"))) { + throw new \Exception("Please enter a valid hex color."); + } + if (!preg_match("/[0-9a-z_-]+/i", $f3->get("POST.username"))) { + throw new \Exception("Usernames can only contain letters, numbers, hyphens, and underscores."); + } + if (!filter_var($f3->get("POST.email"), FILTER_VALIDATE_EMAIL)) { + throw new \Exception("Please enter a valid email address"); + } + + // Set basic fields + $user->username = $f3->get("POST.username"); + $user->email = $f3->get("POST.email"); + $user->name = $f3->get("POST.name"); + // Don't allow user to change own rank + if ($user->id != $f3->get("user.id")) { + $user->rank = $f3->get("POST.rank"); + } + $user->role = $user->rank < \Model\User::RANK_ADMIN ? 'user' : 'admin'; + $user->task_color = ltrim($f3->get("POST.task_color"), "#"); + + // Save user + $user->save(); + } catch (\Exception $e) { + $f3->set("error", $e->getMessage()); + $this->_render("admin/users/edit.html"); + return; + } + + $f3->reroute("/admin/users#" . $user->id); + } + + /** + * POST /admin/users/delete/@id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function user_delete(\Base $f3, array $params) + { + $this->validateCsrf(); + $user = new \Model\User(); + $user->load($params["id"]); + if (!$user->id) { + $f3->reroute("/admin/users"); + return; + } + + // Reassign issues if requested + if ($f3->get("POST.reassign")) { + switch ($f3->get("POST.reassign")) { + case "unassign": + $user->reassignIssues(null); + break; + case "to-user": + $user->reassignIssues($f3->get("POST.reassign-to")); + break; + } + } + + $user->delete(); + if ($f3->get("AJAX")) { + $this->_printJson(["deleted" => 1]); + } else { + $f3->reroute("/admin/users"); + } + } + + /** + * POST /admin/users/undelete/@id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function user_undelete(\Base $f3, array $params) + { + $this->validateCsrf(); + $user = new \Model\User(); + $user->load($params["id"]); + if (!$user->id) { + $f3->reroute("/admin/users"); + return; + } + + $user->deleted_date = null; + $user->save(); + + if ($f3->get("AJAX")) { + $this->_printJson(["deleted" => 1]); + } else { + $f3->reroute("/admin/users"); + } + } + + /** + * GET /admin/groups + * @param \Base $f3 + */ + public function groups(\Base $f3) + { + $f3->set("title", $f3->get("dict.groups")); + + $group = new \Model\User(); + $groups = $group->find("deleted_date IS NULL AND role = 'group'"); + + $group_array = []; + $db = $f3->get("db.instance"); + foreach ($groups as $g) { + $db->exec("SELECT g.id FROM user_group g JOIN user u ON g.user_id = u.id WHERE g.group_id = ? AND u.deleted_date IS NULL", $g["id"]); + $count = $db->count(); + $group_array[] = [ + "id" => $g["id"], + "name" => $g["name"], + "task_color" => $g["task_color"], + "count" => $count, + ]; + } + $f3->set("groups", $group_array); + + $this->_render("admin/groups.html"); + } + + /** + * POST /admin/groups/new + * @param \Base $f3 + */ + public function group_new(\Base $f3) + { + $this->validateCsrf(); + $group = new \Model\User(); + $group->name = $f3->get("POST.name"); + $group->username = \Web::instance()->slug($group->name); + $group->role = "group"; + $group->task_color = sprintf("%02X%02X%02X", random_int(0, 0xFF), random_int(0, 0xFF), random_int(0, 0xFF)); + $group->created_date = $this->now(); + $group->save(); + $f3->reroute("/admin/groups"); + } + + /** + * GET /admin/groups/@id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function group_edit(\Base $f3, array $params) + { + $f3->set("title", $f3->get("dict.groups")); + + $group = new \Model\User(); + $group->load(["id = ? AND deleted_date IS NULL AND role = 'group'", $params["id"]]); + $f3->set("group", $group); + + $members = new \Model\Custom("user_group_user"); + $f3->set("members", $members->find(["group_id = ? AND deleted_date IS NULL", $group->id])); + + $users = new \Model\User(); + $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", ["order" => "name ASC"])); + + $this->_render("admin/groups/edit.html"); + } + + /** + * POST /admin/groups/delete/@id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function group_delete(\Base $f3, array $params) + { + $this->validateCsrf(); + $group = new \Model\User(); + $group->load($params["id"]); + $group->delete(); + if ($f3->get("AJAX")) { + $this->_printJson(["deleted" => 1] + $group->cast()); + } else { + $f3->reroute("/admin/groups"); + } + } + + /** + * POST /admin/groups/ajax + * @param \Base $f3 + * @throws \Exception + */ + public function group_ajax(\Base $f3) + { + $this->validateCsrf(); + if (!$f3->get("AJAX")) { + $f3->error(400); + } + + $group = new \Model\User(); + $group->load(["id = ? AND deleted_date IS NULL AND role = 'group'", $f3->get("POST.group_id")]); + + if (!$group->id) { + $f3->error(404); + return; + } + + switch ($f3->get('POST.action')) { + case "add_member": + foreach ($f3->get("POST.user") as $user_id) { + $user_group = new \Model\User\Group(); + $user_group->load(["user_id = ? AND group_id = ?", $user_id, $f3->get("POST.group_id")]); + if (!$user_group->id) { + $user_group->group_id = $f3->get("POST.group_id"); + $user_group->user_id = $user_id; + $user_group->save(); + } else { + // user already in group + } + } + break; + case "remove_member": + $user_group = new \Model\User\Group(); + $user_group->load(["user_id = ? AND group_id = ?", $f3->get("POST.user_id"), $f3->get("POST.group_id")]); + $user_group->delete(); + $this->_printJson(["deleted" => 1]); + break; + case "change_title": + $group->name = trim($f3->get("POST.name")); + $group->username = \Web::instance()->slug($group->name); + $group->save(); + $this->_printJson(["changed" => 1]); + break; + case "change_task_color": + $group->task_color = ltrim($f3->get("POST.value"), '#'); + $group->save(); + $this->_printJson(["changed" => 1]); + break; + case "change_api_visibility": + $group->api_visible = (int)!!$f3->get("POST.value"); + $group->save(); + $this->_printJson(["changed" => 1]); + break; + } + } + + /** + * POST /admin/groups/@id/setmanager/@user_group_id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function group_setmanager(\Base $f3, array $params) + { + $this->validateCsrf(); + $db = $f3->get("db.instance"); + + $group = new \Model\User(); + $group->load(["id = ? AND deleted_date IS NULL AND role = 'group'", $params["id"]]); + + if (!$group->id) { + $f3->error(404); + return; + } + + // Remove Manager status from all members and set manager status on specified user + $db->exec("UPDATE user_group SET manager = 0 WHERE group_id = ?", $group->id); + $db->exec("UPDATE user_group SET manager = 1 WHERE id = ?", $params["user_group_id"]); + + $f3->reroute("/admin/groups/" . $group->id); + } + + /** + * GET /admin/sprints + * @param \Base $f3 + */ + public function sprints(\Base $f3) + { + $f3->set("title", $f3->get("dict.sprints")); + + $sprints = new \Model\Sprint(); + $f3->set("sprints", $sprints->find()); + + $this->_render("admin/sprints.html"); + } + + /** + * GET|POST /admin/sprints/new + * @param \Base $f3 + */ + public function sprint_new(\Base $f3) + { + $f3->set("title", $f3->get("dict.sprints")); + + if ($post = $f3->get("POST")) { + $this->validateCsrf(); + + if (empty($post["start_date"]) || empty($post["end_date"])) { + $f3->set("error", "Start and end date are required"); + $this->_render("admin/sprints/new.html"); + return; + } + + $start = strtotime($post["start_date"]); + $end = strtotime($post["end_date"]); + + if (!$start || !$end) { + $f3->set("error", "Please enter a valid start and end date"); + $this->_render("admin/sprints/new.html"); + return; + } + + if ($end <= $start) { + $f3->set("error", "End date must be after start date"); + $this->_render("admin/sprints/new.html"); + return; + } + + $sprint = new \Model\Sprint(); + $sprint->name = trim($post["name"]); + $sprint->start_date = date("Y-m-d", $start); + $sprint->end_date = date("Y-m-d", $end); + $sprint->save(); + $f3->reroute("/admin/sprints"); + return; + } + + $this->_render("admin/sprints/new.html"); + } + + /** + * GET /admin/sprints/@id, POST /admin/sprints/@id + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function sprint_edit(\Base $f3, array $params) + { + $f3->set("title", $f3->get("dict.sprints")); + + $sprint = new \Model\Sprint(); + $sprint->load($params["id"]); + if (!$sprint->id) { + $f3->error(404); + return; + } + + if ($post = $f3->get("POST")) { + if (empty($post["start_date"]) || empty($post["end_date"])) { + $f3->set("error", "Start and end date are required"); + $this->_render("admin/sprints/edit.html"); + return; + } + + $start = strtotime($post["start_date"]); + $end = strtotime($post["end_date"]); + + if ($end <= $start) { + $f3->set("error", "End date must be after start date"); + $this->_render("admin/sprints/edit.html"); + return; + } + + $sprint->name = trim($post["name"]); + $sprint->start_date = date("Y-m-d", $start); + $sprint->end_date = date("Y-m-d", $end); + $sprint->save(); + + $f3->reroute("/admin/sprints"); + return; + } + $f3->set("sprint", $sprint); + + $this->_render("admin/sprints/edit.html"); + } } diff --git a/app/controller/api.php b/app/controller/api.php index 34ba8919..7f6bbf69 100644 --- a/app/controller/api.php +++ b/app/controller/api.php @@ -2,66 +2,69 @@ namespace Controller; -abstract class Api extends \Controller { +abstract class Api extends \Controller +{ + protected $_userId; - protected $_userId; + public function __construct() + { + $f3 = \Base::instance(); + $f3->set("ONERROR", function (\Base $f3) { + if (!headers_sent()) { + header("Content-type: application/json"); + } + $out = [ + "status" => $f3->get("ERROR.code"), + "error" => $f3->get("ERROR.text"), + ]; + if ($f3->get("DEBUG") >= 2) { + $out["trace"] = strip_tags($f3->get("ERROR.trace")); + } + echo json_encode($out, JSON_THROW_ON_ERROR); + }); - function __construct() { - $f3 = \Base::instance(); - $f3->set("ONERROR", function(\Base $f3) { - if(!headers_sent()) { - header("Content-type: application/json"); - } - $out = array( - "status" => $f3->get("ERROR.code"), - "error" => $f3->get("ERROR.text") - ); - if($f3->get("DEBUG") >= 2) { - $out["trace"] = strip_tags($f3->get("ERROR.trace")); - } - echo json_encode($out); - }); + $this->_userId = $this->_requireAuth(); + } - $this->_userId = $this->_requireAuth(); - } + /** + * Require an API key. Sends an HTTP 401 if one is not supplied. + * @return int|bool + */ + protected function _requireAuth() + { + $f3 = \Base::instance(); - /** - * Require an API key. Sends an HTTP 401 if one is not supplied. - * @return int|bool - */ - protected function _requireAuth() { - $f3 = \Base::instance(); + $user = new \Model\User(); - $user = new \Model\User(); + // Use the logged in user if there is one + if ($f3->get("user.api_key")) { + $key = $f3->get("user.api_key"); + } else { + $key = false; + } - // Use the logged in user if there is one - if($f3->get("user.api_key")) { - $key = $f3->get("user.api_key"); - } else { - $key = false; - } + // Check all supported key methods + if (!empty($_GET["key"])) { + $key = $_GET["key"]; + } elseif ($f3->get("HEADERS.X-Redmine-API-Key")) { + $key = $f3->get("HEADERS.X-Redmine-API-Key"); + } elseif ($f3->get("HEADERS.X-API-Key")) { + $key = $f3->get("HEADERS.X-API-Key"); + } elseif ($f3->get("HEADERS.X-Api-Key")) { + $key = $f3->get("HEADERS.X-Api-Key"); + } elseif (isset($_SERVER['HTTP_X_API_KEY'])) { + $key = $_SERVER['HTTP_X_API_KEY']; + } - // Check all supported key methods - if(!empty($_GET["key"])) { - $key = $_GET["key"]; - } elseif($f3->get("HEADERS.X-Redmine-API-Key")) { - $key = $f3->get("HEADERS.X-Redmine-API-Key"); - } elseif($f3->get("HEADERS.X-API-Key")) { - $key = $f3->get("HEADERS.X-API-Key"); - } elseif($f3->get("HEADERS.X-Api-Key")) { - $key = $f3->get("HEADERS.X-Api-Key"); - } - - $user->load(array("api_key = ?", $key)); - - if($key && $user->id && $user->api_key) { - $f3->set("user", $user->cast()); - $f3->set("user_obj", $user); - return $user->id; - } else { - $f3->error(401); - return false; - } - } + $user->load(["api_key = ?", $key]); + if ($key && $user->id && $user->api_key) { + $f3->set("user", $user->cast()); + $f3->set("user_obj", $user); + return $user->id; + } else { + $f3->error(401); + return false; + } + } } diff --git a/app/controller/api/issues.php b/app/controller/api/issues.php index bb4335f8..82ca9181 100644 --- a/app/controller/api/issues.php +++ b/app/controller/api/issues.php @@ -2,368 +2,375 @@ namespace Controller\Api; -class Issues extends \Controller\Api { - - /** - * Converts an issue into a Redmine API-style multidimensional array - * This isn't pretty. - * @param Detail $issue - * @return array - */ - protected function _issueMultiArray(\Model\Issue\Detail $issue) { - $casted = $issue->cast(); - - // Convert ALL the fields! - $result = array(); - $result["tracker"] = array( - "id" => $issue->type_id, - "name" => $issue->type_name - ); - $result["status"] = array( - "id" => $issue->status, - "name" => $issue->status_name - ); - $result["priority"] = array( - "id" => $issue->priority_id, - "name" => $issue->priority_name - ); - $result["author"] = array( - "id" => $issue->author_id, - "name" => $issue->author_name, - "username" => $issue->author_username, - "email" => $issue->author_email, - "task_color" => $issue->author_task_color - ); - $result["owner"] = array( - "id" => $issue->owner_id, - "name" => $issue->owner_name, - "username" => $issue->owner_username, - "email" => $issue->owner_email, - "task_color" => $issue->owner_task_color - ); - if(!empty($issue->sprint_id)) { - $result["sprint"] = array( - "id" => $issue->sprint_id, - "name" => $issue->sprint_name, - "start_date" => $issue->sprint_start_date, - "end_date" => $issue->sprint_end_date, - ); - } - - // Remove redundant fields - foreach($issue->schema() as $i=>$val) { - if(preg_match("/(type|status|priority|author|owner|sprint)_.+|has_due_date/", $i)) { - unset($casted[$i]); - } - } - - return array_replace($casted, $result); - } - - // Get a list of issues - public function get($f3) { - $issue = new \Model\Issue\Detail(); - - // Build filter string - $filter = array(); - $get = $f3->get("GET"); - $db = $f3->get("db.instance"); - foreach($issue->fields(false) as $i) { - if(isset($get[$i])) { - $filter[] = "`$i` = " . $db->quote($get[$i]); - } - } - $filter_str = $filter ? implode(' AND ', $filter) : null; - - // Build options - $options = array(); - if($f3->get("GET.order")) { - $options["order"] = $f3->get("GET.order") . " " . $f3->get("GET.ascdesc"); - } - - // Load issues - $result = $issue->paginate( - $f3->get("GET.offset") / ($f3->get("GET.limit") ?: 30), - $f3->get("GET.limit") ?: 30, - $filter_str, $options - ); - - // Build result objects - $issues = array(); - foreach($result["subset"] as $iss) { - $issues[] = $this->_issueMultiArray($iss); - } - - // Output response - $this->_printJson(array( - "total_count" => $result["total"], - "limit" => $result["limit"], - "issues" => $issues, - "offset" => $result["pos"] * $result["limit"] - )); - } - - // Create a new issue - public function post($f3) { - if($_REQUEST) { - // By default, use standard HTTP POST fields - $post = $_REQUEST; - } else { - - // For Redmine compatibility, also accept a JSON object - try { - $post = json_decode(file_get_contents('php://input'), true); - } catch (Exception $e) { - throw new Exception("Unable to parse input"); - } - - if(!empty($post["issue"])) { - $post = $post["issue"]; - } - - // Convert Redmine names to Phproject names - if(!empty($post["subject"])) { - $post["name"] = $post["subject"]; - } - if(!empty($post["parent_issue_id"])) { - $post["parent_id"] = $post["parent_issue_id"]; - } - if(!empty($post["tracker_id"])) { - $post["type_id"] = $post["tracker_id"]; - } - if(!empty($post["assigned_to_id"])) { - $post["owner_id"] = $post["assigned_to_id"]; - } - if(!empty($post["fixed_version_id"])) { - $post["sprint_id"] = $post["fixed_version_id"]; - } - - } - - // Ensure a status ID is added - if(!empty($post["status_id"])) { - $post["status"] = $post["status_id"]; - } - if(empty($post["status"])) { - $post["status"] = 1; - } - - - // Verify the required "name" field is passed - if(empty($post["name"])) { - $f3->error("The 'name' value is required."); - return; - } - - // Verify given values are valid (types, statueses, priorities) - if(!empty($post["type_id"])) { - $type = new \Model\Issue\Type; - $type->load($post["type_id"]); - if(!$type->id) { - $f3->error("The 'type_id' field is not valid."); - return; - } - } - if(!empty($post["parent_id"])) { - $parent = new \Model\Issue; - $parent->load($post["parent_id"]); - if(!$parent->id) { - $f3->error("The 'type_id' field is not valid."); - return; - } - } - if(!empty($post["status"])) { - $status = new \Model\Issue\Status; - $status->load($post["status"]); - if(!$status->id) { - $f3->error("The 'status' field is not valid."); - return; - } - } - if(!empty($post["priority_id"])) { - $priority = new \Model\Issue\Priority; - $priority->load(array("value" => $post["priority_id"])); - if(!$priority->id) { - $f3->error("The 'priority_id' field is not valid."); - return; - } - } - - // Create a new issue based on the data - $issue = new \Model\Issue(); - - $issue->author_id = !empty($post["author_id"]) ? $post["author_id"] : $this->_userId; - $issue->name = trim($post["name"]); - $issue->type_id = empty($post["type_id"]) ? 1 : $post["type_id"]; - $issue->priority_id = empty($post["priority_id"]) ? $f3->get("issue_priority.default") : $post["priority_id"]; - $issue->status = empty($status) ? 1 : $status->id; - - // Set due date if valid - if(!empty($post["due_date"]) && preg_match("/^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}( [0-9:]{8})?$/", $post["due_date"])) { - $issue->due_date = $post["due_date"]; - } elseif(!empty($post["due_date"]) && $due_date = strtotime($post["due_date"])) { - $issue->due_date = date("Y-m-d", $due_date); - } - - if(!empty($post["description"])) { - $issue->description = $post["description"]; - } - if(!empty($post["parent_id"])) { - $issue->parent_id = $post["parent_id"]; - } - if(!empty($post["owner_id"])) { - $issue->owner_id = $post["owner_id"]; - } - - $issue->save(); - - $notification = \Helper\Notification::instance(); - $notification->issue_create($issue->id); - - $this->_printJson(array( - "issue" => $issue->cast() - )); - } - - // Update an existing issue - public function single_put($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if(!$issue->id) { - $f3->error(404); - return; - } - - $updated = array(); - foreach($f3->get("REQUEST") as $key => $val) { - if(is_scalar($val) && $issue->exists($key)) { - $updated[] = $key; - $issue->set($key, $val); - } - } - - if($updated) { - $issue->save(); - } - - $this->printJson(array("updated_fields" => $updated, "issue" => $this->_issueMultiArray($issue))); - } - - // Get a single issue's details - public function single_get($f3, $params) { - $issue = new \Model\Issue\Detail; - $issue->load($params["id"]); - if($issue->id) { - $this->_printJson(array("issue" => $this->_issueMultiArray($issue))); - } else { - $f3->error(404); - } - } - - // Delete a single issue - public function single_delete($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - $issue->delete(); - - if(!$issue->id) { - $f3->error(404); - return; - } - - $this->_printJson(array( - "deleted" => $params["id"] - )); - } - - // List issue comments - public function single_comments($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - if(!$issue->id) { - $f3->error(404); - return; - } - - $comment = new \Model\Issue\Comment\Detail; - $comments = $comment->find(array("issue_id = ?", $issue->id), array("order" => "created_date DESC")); - - $return = array(); - foreach($comments as $item) { - $return[] = $item->cast(); - } - - $this->_printJson($return); - } - - // Add a comment on an issue - public function single_comments_post($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - if(!$issue->id) { - $f3->error(404); - return; - } - - $data = array("issue_id" => $issue->id, "user_id" => $this->_userId, "text" => $f3->get("POST.text")); - $comment = \Model\Issue\Comment::create($data); - $this->_printJson($comment->cast()); - } - - // List issue types - public function types($f3) { - $types = $f3->get("issue_types"); - $return = array(); - foreach($types as $type) { - $return[] = $type->cast(); - } - $this->_printJson($return); - } - - // List issue tags - public function tag() { - $tag = new \Model\Issue\Tag; - $tags = $tag->cloud(); - $this->_printJson($tags); - } - - // List issues by tag - public function tag_single($f3, $params) { - $tag = new \Model\Issue\Tag; - $issueIds = $tag->issues($params['tag']); - $return = array(); - if($issueIds) { - $issue = new \Model\Issue\Detail; - $issues = $issue->find(array("id IN (" . implode(",", $issueIds) . ") AND deleted_date IS NULL")); - foreach($issues as $item) { - $return[] = $this->_issueMultiArray($item); - } - } - - $this->_printJson($return); - } - - // List sprints - public function sprints() { - $sprint_model = new \Model\Sprint; - $sprints = $sprint_model->find(array("end_date >= ?", $this->now(false)), array("order" => "start_date ASC")); - $return = array(); - foreach($sprints as $sprint) { - $return[] = $sprint->cast(); - } - $this->_printJson($return); - } - - // List past sprints - public function sprints_old() { - $sprint_model = new \Model\Sprint; - $sprints = $sprint_model->find(array("end_date < ?", $this->now(false)), array("order" => "start_date ASC")); - $return = array(); - foreach($sprints as $sprint) { - $return[] = $sprint->cast(); - } - $this->_printJson($return); - } - +class Issues extends \Controller\Api +{ + /** + * Converts an issue into a Redmine API-style multidimensional array + * This isn't pretty. + * @param Detail $issue + * @return array + */ + protected function _issueMultiArray(\Model\Issue\Detail $issue) + { + $casted = $issue->cast(); + + // Convert ALL the fields! + $result = []; + $result["tracker"] = [ + "id" => $issue->type_id, + "name" => $issue->type_name + ]; + $result["status"] = [ + "id" => $issue->status, + "name" => $issue->status_name + ]; + $result["priority"] = [ + "id" => $issue->priority_id, + "name" => $issue->priority_name + ]; + $result["author"] = [ + "id" => $issue->author_id, + "name" => $issue->author_name, + "username" => $issue->author_username, + "email" => $issue->author_email, + "task_color" => $issue->author_task_color + ]; + $result["owner"] = [ + "id" => $issue->owner_id, + "name" => $issue->owner_name, + "username" => $issue->owner_username, + "email" => $issue->owner_email, + "task_color" => $issue->owner_task_color + ]; + if (!empty($issue->sprint_id)) { + $result["sprint"] = [ + "id" => $issue->sprint_id, + "name" => $issue->sprint_name, + "start_date" => $issue->sprint_start_date, + "end_date" => $issue->sprint_end_date + ]; + } + + // Remove redundant fields + foreach (array_keys($issue->schema()) as $key) { + if (preg_match("/(type|status|priority|author|owner|sprint)_.+|has_due_date/", $key)) { + unset($casted[$key]); + } + } + + return array_replace($casted, $result); + } + + // Get a list of issues + public function get($f3) + { + $issue = new \Model\Issue\Detail(); + + // Build filter string + $filter = []; + $get = $f3->get("GET"); + $db = $f3->get("db.instance"); + foreach ($issue->fields(false) as $i) { + if (isset($get[$i])) { + $filter[] = "`$i` = " . $db->quote($get[$i]); + } + } + $filter_str = $filter ? implode(' AND ', $filter) : null; + + // Build options + $options = []; + if ($f3->get("GET.order")) { + $options["order"] = $f3->get("GET.order") . " " . $f3->get("GET.ascdesc"); + } + + // Load issues + $result = $issue->paginate( + $f3->get("GET.offset") / ($f3->get("GET.limit") ?: 30), + $f3->get("GET.limit") ?: 30, + $filter_str, + $options + ); + + // Build result objects + $issues = []; + foreach ($result["subset"] as $iss) { + $issues[] = $this->_issueMultiArray($iss); + } + + // Output response + $this->_printJson([ + "total_count" => $result["total"], + "limit" => $result["limit"], + "issues" => $issues, + "offset" => $result["pos"] * $result["limit"], + ]); + } + + // Create a new issue + public function post($f3) + { + if ($_REQUEST) { + // By default, use standard HTTP POST fields + $post = $_REQUEST; + } else { + // For Redmine compatibility, also accept a JSON object + try { + $post = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + throw new \Exception("Unable to parse input"); + } + + if (!empty($post["issue"])) { + $post = $post["issue"]; + } + + // Convert Redmine names to Phproject names + if (!empty($post["subject"])) { + $post["name"] = $post["subject"]; + } + if (!empty($post["parent_issue_id"])) { + $post["parent_id"] = $post["parent_issue_id"]; + } + if (!empty($post["tracker_id"])) { + $post["type_id"] = $post["tracker_id"]; + } + if (!empty($post["assigned_to_id"])) { + $post["owner_id"] = $post["assigned_to_id"]; + } + if (!empty($post["fixed_version_id"])) { + $post["sprint_id"] = $post["fixed_version_id"]; + } + } + + // Ensure a status ID is added + if (!empty($post["status_id"])) { + $post["status"] = $post["status_id"]; + } + if (empty($post["status"])) { + $post["status"] = 1; + } + + + // Verify the required "name" field is passed + if (empty($post["name"])) { + $f3->error("The 'name' value is required."); + return; + } + + // Verify given values are valid (types, statuses, priorities) + if (!empty($post["type_id"])) { + $type = new \Model\Issue\Type(); + $type->load($post["type_id"]); + if (!$type->id) { + $f3->error("The 'type_id' field is not valid."); + return; + } + } + if (!empty($post["parent_id"])) { + $parent = new \Model\Issue(); + $parent->load($post["parent_id"]); + if (!$parent->id) { + $f3->error("The 'type_id' field is not valid."); + return; + } + } + if (!empty($post["status"])) { + $status = new \Model\Issue\Status(); + $status->load($post["status"]); + if (!$status->id) { + $f3->error("The 'status' field is not valid."); + return; + } + } + if (!empty($post["priority_id"])) { + $priority = new \Model\Issue\Priority(); + $priority->load(["value" => $post["priority_id"]]); + if (!$priority->id) { + $f3->error("The 'priority_id' field is not valid."); + return; + } + } + + // Create a new issue based on the data + $issue = new \Model\Issue(); + + $issue->author_id = !empty($post["author_id"]) ? $post["author_id"] : $this->_userId; + $issue->name = trim($post["name"]); + $issue->type_id = empty($post["type_id"]) ? 1 : $post["type_id"]; + $issue->priority_id = empty($post["priority_id"]) ? $f3->get("issue_priority.default") : $post["priority_id"]; + $issue->status = empty($status) ? 1 : $status->id; + + // Set due date if valid + if (!empty($post["due_date"]) && preg_match("/^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}( [0-9:]{8})?$/", $post["due_date"])) { + $issue->due_date = $post["due_date"]; + } elseif (!empty($post["due_date"]) && $due_date = strtotime($post["due_date"])) { + $issue->due_date = date("Y-m-d", $due_date); + } + + if (!empty($post["description"])) { + $issue->description = $post["description"]; + } + if (!empty($post["parent_id"])) { + $issue->parent_id = $post["parent_id"]; + } + if (!empty($post["owner_id"])) { + $issue->owner_id = $post["owner_id"]; + } + + $issue->save(); + + $notification = \Helper\Notification::instance(); + $notification->issue_create($issue->id); + + $this->_printJson(["issue" => $issue->cast()]); + } + + // Update an existing issue + public function single_put($f3, $params) + { + $issue = new \Model\Issue(); + $issue->load($params["id"]); + + if (!$issue->id) { + $f3->error(404); + return; + } + + $updated = []; + foreach ($f3->get("REQUEST") as $key => $val) { + if (is_scalar($val) && $issue->exists($key)) { + $updated[] = $key; + $issue->set($key, $val); + } + } + + if ($updated) { + $issue->save(); + } + + $this->_printJson(["updated_fields" => $updated, "issue" => $this->_issueMultiArray($issue)]); + } + + // Get a single issue's details + public function single_get($f3, $params) + { + $issue = new \Model\Issue\Detail(); + $issue->load($params["id"]); + if ($issue->id) { + $this->_printJson(["issue" => $this->_issueMultiArray($issue)]); + } else { + $f3->error(404); + } + } + + // Delete a single issue + public function single_delete($f3, $params) + { + $issue = new \Model\Issue(); + $issue->load($params["id"]); + $issue->delete(); + + if (!$issue->id) { + $f3->error(404); + return; + } + + $this->_printJson(["deleted" => $params["id"]]); + } + + // List issue comments + public function single_comments($f3, $params) + { + $issue = new \Model\Issue(); + $issue->load($params["id"]); + if (!$issue->id) { + $f3->error(404); + return; + } + + $comment = new \Model\Issue\Comment\Detail(); + $comments = $comment->find(["issue_id = ?", $issue->id], ["order" => "created_date DESC"]); + + $return = []; + foreach ($comments as $item) { + $return[] = $item->cast(); + } + + $this->_printJson($return); + } + + // Add a comment on an issue + public function single_comments_post($f3, $params) + { + $issue = new \Model\Issue(); + $issue->load($params["id"]); + if (!$issue->id) { + $f3->error(404); + return; + } + + $data = ["issue_id" => $issue->id, "user_id" => $this->_userId, "text" => $f3->get("POST.text")]; + $comment = \Model\Issue\Comment::create($data); + $this->_printJson($comment->cast()); + } + + // List issue types + public function types($f3) + { + $types = $f3->get("issue_types"); + $return = []; + foreach ($types as $type) { + $return[] = $type->cast(); + } + $this->_printJson($return); + } + + // List issue tags + public function tag() + { + $tag = new \Model\Issue\Tag(); + $tags = $tag->cloud(); + $this->_printJson($tags); + } + + // List issues by tag + public function tag_single($f3, $params) + { + $tag = new \Model\Issue\Tag(); + $issueIds = $tag->issues($params['tag']); + $return = []; + if ($issueIds) { + $issue = new \Model\Issue\Detail(); + $issues = $issue->find(["id IN (" . implode(",", $issueIds) . ") AND deleted_date IS NULL"]); + foreach ($issues as $item) { + $return[] = $this->_issueMultiArray($item); + } + } + + $this->_printJson($return); + } + + // List sprints + public function sprints() + { + $sprint_model = new \Model\Sprint(); + $sprints = $sprint_model->find(["end_date >= ?", $this->now(false)], ["order" => "start_date ASC"]); + $return = []; + foreach ($sprints as $sprint) { + $return[] = $sprint->cast(); + } + $this->_printJson($return); + } + + // List past sprints + public function sprints_old() + { + $sprint_model = new \Model\Sprint(); + $sprints = $sprint_model->find(["end_date < ?", $this->now(false)], ["order" => "start_date ASC"]); + $return = []; + foreach ($sprints as $sprint) { + $return[] = $sprint->cast(); + } + $this->_printJson($return); + } } diff --git a/app/controller/api/user.php b/app/controller/api/user.php index 756927b5..ec219fa2 100644 --- a/app/controller/api/user.php +++ b/app/controller/api/user.php @@ -2,117 +2,118 @@ namespace Controller\Api; -class User extends \Controller\Api { - - protected function user_array(\Model\User $user) { - - $group_id = $user->id; - - if($user->role == 'group') { - $group = new \Model\Custom("user_group"); - $man = $group->find(array("group_id = ? AND manager = 1", $user->id)); - $man = array_filter($man); - - if(!empty($man) && $man[0]->user_id > 0) { - $group_id = $man[0]->user_id; - } - } - - $result = array( - "id" => $group_id, - "name" => $user->name, - "username" => $user->username, - "email" => $user->email - ); - - return ($result); - } - - public function single_get($f3, $params) { - if($params["username"] == "me") { - $user = $f3->get("user_obj"); - } else { - $user = new \Model\User(); - $user->load(array("username = ?", $params["username"])); - } - if($user->id) { - $this->_printJson($this->user_array($user)); - } else { - $f3->error(404); - } - } - - public function single_email($f3, $params) { - $user = new \Model\User(); - $user->load(array("email = ? AND deleted_date IS NULL", $params["email"])); - if($user->id) { - $this->_printJson($this->user_array($user)); - } else { - $f3->error(404); - } - } - - - // Gets a List of uers - public function get($f3) { - $pagLimit = $f3->get("GET.limit") ?: 30; - if($pagLimit == -1) { - $pagLimit = 100000; - } elseif ($pagLimit < 0) { - $pagLimit = 30; - } - - $user = new \Model\User; - $result = $user->paginate( - $f3->get("GET.offset") / $pagLimit, - $pagLimit, - "deleted_date IS NULL AND role != 'group'" - ); - - $users = array(); - foreach ($result["subset"] as $user) { - $users[] = $this->user_array($user); - } - - $this->_printJson(array( - "total_count" => $result["total"], - "limit" => $result["limit"], - "users" => $users, - "offset" => $result["pos"] * $result["limit"] - )); - } - - - // Gets a list of Uers - public function get_group($f3) { - - $pagLimit = $f3->get("GET.limit") ?: 30; - - if($pagLimit == -1) { - $pagLimit = 100000; - } elseif ($pagLimit < 0) { - $pagLimit = 30; - } - - $user = new \Model\User; - $result = $user->paginate( - $f3->get("GET.offset") / $pagLimit, - $pagLimit, - "deleted_date IS NULL AND role = 'group' AND api_visible != '0'" - ); - - $groups = array(); - foreach ($result["subset"] as $user) { - $groups[] = $this->user_array($user); - } - - $this->_printJson(array( - "total_count" => $result["total"], - "limit" => $result["limit"], - "groups" => $groups, - "offset" => $result["pos"] * $result["limit"] - )); - - } - +class User extends \Controller\Api +{ + protected function user_array(\Model\User $user) + { + $group_id = $user->id; + + if ($user->role == 'group') { + $group = new \Model\Custom("user_group"); + $man = $group->find(["group_id = ? AND manager = 1", $user->id]); + $man = array_filter($man); + + if (!empty($man) && $man[0]->user_id > 0) { + $group_id = $man[0]->user_id; + } + } + + $result = [ + "id" => $group_id, + "name" => $user->name, + "username" => $user->username, + "email" => $user->email, + ]; + + return ($result); + } + + public function single_get($f3, $params) + { + if ($params["username"] == "me") { + $user = $f3->get("user_obj"); + } else { + $user = new \Model\User(); + $user->load(["username = ?", $params["username"]]); + } + if ($user->id) { + $this->_printJson($this->user_array($user)); + } else { + $f3->error(404); + } + } + + public function single_email($f3, $params) + { + $user = new \Model\User(); + $user->load(["email = ? AND deleted_date IS NULL", $params["email"]]); + if ($user->id) { + $this->_printJson($this->user_array($user)); + } else { + $f3->error(404); + } + } + + + // Gets a List of users + public function get($f3) + { + $pagLimit = $f3->get("GET.limit") ?: 30; + if ($pagLimit == -1) { + $pagLimit = 100000; + } elseif ($pagLimit < 0) { + $pagLimit = 30; + } + + $user = new \Model\User(); + $result = $user->paginate( + $f3->get("GET.offset") / $pagLimit, + $pagLimit, + "deleted_date IS NULL AND role != 'group'" + ); + + $users = []; + foreach ($result["subset"] as $user) { + $users[] = $this->user_array($user); + } + + $this->_printJson([ + "total_count" => $result["total"], + "limit" => $result["limit"], + "users" => $users, + "offset" => $result["pos"] * $result["limit"], + ]); + } + + + // Gets a list of Users + public function get_group($f3) + { + $pagLimit = $f3->get("GET.limit") ?: 30; + + if ($pagLimit == -1) { + $pagLimit = 100000; + } elseif ($pagLimit < 0) { + $pagLimit = 30; + } + + $user = new \Model\User(); + $result = $user->paginate( + $f3->get("GET.offset") / $pagLimit, + $pagLimit, + "deleted_date IS NULL AND role = 'group' AND api_visible != '0'" + ); + + $groups = []; + foreach ($result["subset"] as $user) { + $groups[] = $this->user_array($user); + } + + $this->_printJson([ + "total_count" => $result["total"], + "limit" => $result["limit"], + "groups" => $groups, + "offset" => $result["pos"] * $result["limit"], + ]); + } } diff --git a/app/controller/backlog.php b/app/controller/backlog.php index c9f603fe..60e4d36c 100644 --- a/app/controller/backlog.php +++ b/app/controller/backlog.php @@ -2,217 +2,214 @@ namespace Controller; -class Backlog extends \Controller { - - protected $_userId; - - public function __construct() { - $this->_userId = $this->_requireLogin(); - } - - /** - * GET /backlog - * GET /backlog/@filter - * GET /backlog/@filter/@groupid - * - * @param \Base $f3 - * @param array $params - */ - public function index($f3, $params) { - - if(empty($params["filter"])) { - $params["filter"] = "groups"; - } - - if(empty($params["groupid"])) { - $params["groupid"] = ""; - } - - // Get list of all users in the user's groups - if($params["filter"] == "groups") { - $group_model = new \Model\User\Group(); - if(!empty($params["groupid"]) && is_numeric($params["groupid"])) { - //Get users list from a specific Group - $users_result = $group_model->find(array("group_id = ?", $params["groupid"])); - - } else { - //Get users list from all groups that you are in - $groups_result = $group_model->find(array("user_id = ?", $this->_userId)); - $filter_users = array($this->_userId); - foreach($groups_result as $g) { - $filter_users[] = $g["group_id"]; - } - $groups = implode(",", $filter_users); - $users_result = $group_model->find("group_id IN ({$groups})"); - } - - foreach($users_result as $u) { - $filter_users[] = $u["user_id"]; - } - } elseif($params["filter"] == "me") { - //Just get your own id - $filter_users = array($this->_userId); - } - - $filter_string = empty($filter_users) ? "" : "AND owner_id IN (" . implode(",", $filter_users) . ")"; - $f3->set("filter", $params["filter"]); - - $sprint_model = new \Model\Sprint(); - $sprints = $sprint_model->find(array("end_date >= ?", $this->now(false)), array("order" => "start_date ASC")); - - $issue = new \Model\Issue\Detail(); - - $sprint_details = array(); - foreach($sprints as $sprint) { - $projects = $issue->find(array("deleted_date IS NULL AND sprint_id = ? AND type_id = ? $filter_string", $sprint->id, $f3->get("issue_type.project")), - array('order' => 'priority DESC, due_date') - ); - - if(!empty($params["groupid"])) { - // Add sorted projects - $sprintBacklog = array(); - $sortModel = new \Model\Issue\Backlog; - $sortModel->load(array("user_id = ? AND sprint_id = ?", $params["groupid"], $sprint->id)); - $sortArray = array(); - if($sortModel->id) { - $sortArray = json_decode($sortModel->issues); - foreach($sortArray as $id) { - foreach($projects as $p) { - if($p->id == $id) { - $sprintBacklog[] = $p; - } - } - } - } - - // Add remaining projects - foreach($projects as $p) { - if(!in_array($p->id, $sortArray)) { - $sprintBacklog[] = $p; - } - } - } else { - $sprintBacklog = $projects; - } - - $sprint_details[] = $sprint->cast() + array("projects" => $sprintBacklog); - } - - $large_projects = $f3->get("db.instance")->exec("SELECT parent_id FROM issue WHERE parent_id IS NOT NULL AND type_id = ?", $f3->get("issue_type.project")); - $large_project_ids = array(); - foreach($large_projects as $p) { - $large_project_ids[] = $p["parent_id"]; - } - - // Load backlog - if(!empty($large_project_ids)) { - $large_project_ids = implode(",", $large_project_ids); - $unset_projects = $issue->find( - array("deleted_date IS NULL AND sprint_id IS NULL AND type_id = ? AND status_closed = '0' AND id NOT IN ({$large_project_ids}) $filter_string", $f3->get("issue_type.project")), - array('order' => 'priority DESC, due_date') - ); - } else { - $unset_projects = $issue->find( - array("deleted_date IS NULL AND sprint_id IS NULL AND type_id = ? AND status_closed = '0' $filter_string", $f3->get("issue_type.project")), - array('order' => 'priority DESC, due_date') - ); - } - - // Filter projects into sorted and unsorted arrays if filtering by group - if(!empty($params["groupid"])) { - // Add sorted projects - $backlog = array(); - $sortModel = new \Model\Issue\Backlog; - $sortModel->load(array("user_id = ? AND sprint_id IS NULL", $params["groupid"])); - $sortArray = array(); - if($sortModel->id) { - $sortArray = json_decode($sortModel->issues); - foreach($sortArray as $id) { - foreach($unset_projects as $p) { - if($p->id == $id) { - $backlog[] = $p; - } - } - } - } - - // Add remaining projects - $unsorted = array(); - foreach($unset_projects as $p) { - if(!in_array($p->id, $sortArray)) { - $unsorted[] = $p; - } - } - } else { - $backlog = $unset_projects; - } - - $groups = new \Model\User(); - $f3->set("groups", $groups->getAllGroups()); - $f3->set("groupid", $params["groupid"]); - $f3->set("sprints", $sprint_details); - $f3->set("backlog", $backlog); - $f3->set("unsorted", $unsorted); - - $f3->set("title", $f3->get("dict.backlog")); - $f3->set("menuitem", "backlog"); - $this->_render("backlog/index.html"); - } - - /** - * POST /edit - * @param \Base $f3 - * @throws \Exception - */ - public function edit($f3) { - $post = $f3->get("POST"); - $issue = new \Model\Issue(); - $issue->load($post["itemId"]); - $issue->sprint_id = empty($post["reciever"]["receiverId"]) ? null : $post["reciever"]["receiverId"]; - $issue->save(); - $this->_printJson($issue); - } - - /** - * POST /sort - * @param \Base $f3 - * @throws \Exception - */ - public function sort($f3) { - $this->_requireLogin(\Model\User::RANK_MANAGER); - $backlog = new \Model\Issue\Backlog; - if($f3->get("POST.sprint_id")) { - $backlog->load(array("user_id = ? AND sprint_id = ?", $f3->get("POST.user"), $f3->get("POST.sprint_id"))); - $backlog->sprint_id = $f3->get("POST.sprint_id"); - } else { - $backlog->load(array("user_id = ? AND sprint_id IS NULL", $f3->get("POST.user"))); - } - $backlog->user_id = $f3->get("POST.user"); - $backlog->issues = $f3->get("POST.items"); - $backlog->save(); - } - - /** - * GET /backlog/old - * @param \Base $f3 - */ - public function index_old($f3) { - - $sprint_model = new \Model\Sprint(); - $sprints = $sprint_model->find(array("end_date < ?", $this->now(false)), array("order" => "start_date ASC")); - - $issue = new \Model\Issue\Detail(); - - $sprint_details = array(); - foreach($sprints as $sprint) { - $projects = $issue->find(array("deleted_date IS NULL AND sprint_id = ? AND type_id = ?", $sprint->id, $f3->get("issue_type.project"))); - $sprint_details[] = $sprint->cast() + array("projects" => $projects); - } - - $f3->set("sprints", $sprint_details); - - $f3->set("title", $f3->get("dict.backlog")); - $f3->set("menuitem", "backlog"); - $this->_render("backlog/old.html"); - } +class Backlog extends \Controller +{ + protected $_userId; + + public function __construct() + { + $this->_userId = $this->_requireLogin(); + } + + /** + * GET /backlog + * + * @param \Base $f3 + */ + public function index($f3) + { + $sprint_model = new \Model\Sprint(); + $sprints = $sprint_model->find(["end_date >= ?", $this->now(false)], ["order" => "start_date ASC"]); + + $type = new \Model\Issue\Type(); + $projectTypes = $type->find(["role = ?", "project"]); + $f3->set("project_types", $projectTypes); + $typeIds = []; + foreach ($projectTypes as $type) { + $typeIds[] = $type->id; + } + $typeStr = implode(",", $typeIds); + + $issue = new \Model\Issue\Detail(); + + $sprint_details = []; + foreach ($sprints as $sprint) { + $projects = $issue->find( + ["deleted_date IS NULL AND sprint_id = ? AND type_id IN ($typeStr)", $sprint->id], + ['order' => 'priority DESC, due_date'] + ); + + // Add sorted projects + $sprintBacklog = []; + $sortOrder = new \Model\Issue\Backlog(); + $sortOrder->load(["sprint_id = ?", $sprint->id]); + $sortArray = []; + if ($sortOrder->id) { + $sortArray = json_decode($sortOrder->issues, null, 512, JSON_THROW_ON_ERROR) ?? []; + $sortArray = array_unique($sortArray); + foreach ($sortArray as $id) { + foreach ($projects as $p) { + if ($p->id == $id) { + $sprintBacklog[] = $p; + } + } + } + } + + // Add remaining projects + foreach ($projects as $p) { + if (!in_array($p->id, $sortArray)) { + $sprintBacklog[] = $p; + } + } + + $sprint_details[] = $sprint->cast() + ["projects" => $sprintBacklog]; + } + + $large_projects = $f3->get("db.instance")->exec("SELECT i.parent_id FROM issue i JOIN issue_type t ON t.id = i.type_id WHERE i.parent_id IS NOT NULL AND t.role = 'project' AND i.deleted_date IS NULL"); + $large_project_ids = []; + foreach ($large_projects as $p) { + $large_project_ids[] = $p["parent_id"]; + } + + // Load backlog + if (!empty($large_project_ids)) { + $large_project_ids = implode(",", array_unique($large_project_ids)); + $unset_projects = $issue->find( + ["deleted_date IS NULL AND sprint_id IS NULL AND type_id IN ($typeStr) AND status_closed = '0' AND id NOT IN ({$large_project_ids})"], + ['order' => 'priority DESC, due_date'] + ); + } else { + $unset_projects = $issue->find( + ["deleted_date IS NULL AND sprint_id IS NULL AND type_id IN ($typeStr) AND status_closed = '0'"], + ['order' => 'priority DESC, due_date'] + ); + } + + // Add sorted projects + $backlog = []; + $sortOrder = new \Model\Issue\Backlog(); + $sortOrder->load(["sprint_id IS NULL"]); + $sortArray = []; + if ($sortOrder->id) { + $sortArray = json_decode($sortOrder->issues, null, 512, JSON_THROW_ON_ERROR) ?? []; + $sortArray = array_unique($sortArray); + foreach ($sortArray as $id) { + foreach ($unset_projects as $p) { + if ($p->id == $id) { + $backlog[] = $p; + } + } + } + } + + // Add remaining projects + $unsorted = []; + foreach ($unset_projects as $p) { + if (!in_array($p->id, $sortArray)) { + $unsorted[] = $p; + } + } + + $user = new \Model\User(); + $f3->set("groups", $user->getAllGroups()); + + $f3->set("type_ids", $typeIds); + $f3->set("sprints", $sprint_details); + $f3->set("backlog", $backlog); + $f3->set("unsorted", $unsorted); + + $f3->set("title", $f3->get("dict.backlog")); + $f3->set("menuitem", "backlog"); + $this->_render("backlog/index.html"); + } + + /** + * GET /backlog/@filter + * GET /backlog/@filter/@groupid + * @param \Base $f3 + */ + public function redirect(\Base $f3) + { + $f3->reroute("/backlog"); + } + + /** + * POST /edit + * @param \Base $f3 + * @throws \Exception + */ + public function edit($f3) + { + $this->validateCsrf(); + + // Move project + $post = $f3->get("POST"); + $issue = new \Model\Issue(); + $issue->load($post["id"]); + $issue->sprint_id = empty($post["sprint_id"]) ? null : $post["sprint_id"]; + $issue->save(); + + // Move tasks within project + $tasks = $issue->find([ + 'parent_id = ? AND type_id IN (SELECT id FROM issue_type WHERE role = "task")', + $issue->id, + ]); + foreach ($tasks as $task) { + $task->sprint_id = $issue->sprint_id; + $task->save(); + } + } + + /** + * POST /sort + * @param \Base $f3 + * @throws \Exception + */ + public function sort($f3) + { + $this->validateCsrf(); + $this->_requireLogin(\Model\User::RANK_MANAGER); + $backlog = new \Model\Issue\Backlog(); + if ($f3->get("POST.sprint_id")) { + $backlog->load(["sprint_id = ?", $f3->get("POST.sprint_id")]); + $backlog->sprint_id = $f3->get("POST.sprint_id"); + } else { + $backlog->load(["sprint_id IS NULL"]); + } + $backlog->issues = $f3->get("POST.items"); + $backlog->save(); + } + + /** + * GET /backlog/old + * @param \Base $f3 + */ + public function index_old($f3) + { + $sprint_model = new \Model\Sprint(); + $sprints = $sprint_model->find(["end_date < ?", $this->now(false)], ["order" => "start_date DESC"]); + + $type = new \Model\Issue\Type(); + $projectTypes = $type->find(["role = ?", "project"]); + $f3->set("project_types", $projectTypes); + $typeIds = []; + foreach ($projectTypes as $type) { + $typeIds[] = $type->id; + } + $typeStr = implode(",", $typeIds); + + $issue = new \Model\Issue\Detail(); + $sprint_details = []; + foreach ($sprints as $sprint) { + $projects = $issue->find(["deleted_date IS NULL AND sprint_id = ? AND type_id IN ($typeStr)", $sprint->id]); + $sprint_details[] = $sprint->cast() + ["projects" => $projects]; + } + + $f3->set("sprints", $sprint_details); + + $f3->set("title", $f3->get("dict.backlog")); + $f3->set("menuitem", "backlog"); + $this->_render("backlog/old.html"); + } } diff --git a/app/controller/files.php b/app/controller/files.php index 4ec3e79f..565918c5 100644 --- a/app/controller/files.php +++ b/app/controller/files.php @@ -2,261 +2,260 @@ namespace Controller; -class Files extends \Controller { - - /** - * Forces the framework to use the local filesystem cache method if possible - */ - protected function _useFileCache() { - $f3 = \Base::instance(); - $f3->set("CACHE", "folder=" . $f3->get("TEMP") . "cache/"); - } - - /** - * Send a file to the browser - * @param string $file - * @param string $mime - * @param string $filename - * @param bool $force - * @return int|bool - */ - protected function _sendFile($file, $mime = "", $filename = "", $force = true) { - if (!is_file($file)) { - return FALSE; - } - - $size = filesize($file); - - if(!$mime) { - $mime = \Web::instance()->mime($file); - } - header("Content-Type: $mime"); - - if ($force) { - if(!$filename) { - $filename = basename($file); - } - header("Content-Disposition: attachment; filename=\"$filename\""); - } - - header("Accept-Ranges: bytes"); - header("Content-Length: $size"); - header("X-Powered-By: " . \Base::instance()->get("PACKAGE")); - - readfile($file); - return $size; - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function thumb($f3, $params) { - $this->_useFileCache(); - $cache = \Cache::instance(); - - // Ensure proper content-type for JPEG images - if($params["format"] == "jpg") { - $params["format"] = "jpeg"; - } - - // Output cached image if one exists - $hash = $f3->hash($f3->get('VERB') . " " . $f3->get('URI')) . ".thm"; - if($cache->exists($hash, $data)) { - header("Content-type: image/" . $params["format"]); - echo $data; - return; - } - - $file = new \Model\Issue\File(); - $file->load($params["id"]); - - if(!$file->id) { - $f3->error(404); - return; - } - - $fg = 0x000000; - $bg = 0xFFFFFF; - - // Generate thumbnail of image file - if(substr($file->content_type, 0, 6) == "image/") { - if(is_file($file->disk_filename)) { - $img = new \Helper\Image($file->disk_filename); - $hide_ext = true; - } else { - $protocol = isset($_SERVER["SERVER_PROTOCOL"]) ? $_SERVER["SERVER_PROTOCOL"] : "HTTP/1.0"; - header($protocol . " 404 Not Found"); - $img = new \Helper\Image("img/404.png"); - } - $img->resize($params["size"], $params["size"]); - - $fg = 0xFFFFFF; - $bg = 0x000000; - } - - // Generate thumbnail of text contents - elseif(substr($file->content_type, 0, 5) == "text/") { - - // Get first 2KB of file - $fh = fopen($file->disk_filename, "r"); - $str = fread($fh, 2048); - fclose($fh); - - // Replace tabs with spaces - $str = str_replace("\t", " ", $str); - - $img = new \Helper\Image(); - $img->create($params["size"], $params["size"]); - $img->fill(0xFFFFFF); - $img->text($str, round(0.05 * $params["size"]), 0, round(0.03 * $params["size"]), round(0.03 * $params["size"]), 0x777777); - - // Show file type icon if available - if($file->content_type == "text/csv" || $file->content_type == "text/tsv") { - $icon = new \Image("img/mime/table.png"); - $img->overlay($icon); - } - } - - // Generate thumbnail of MS Office document - elseif(extension_loaded("zip") - && $file->content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document") { - $zip = zip_open($file->disk_filename); - while(($entry = zip_read($zip)) !== false) { - if(preg_match("/word\/media\/image[0-9]+\.(png|jpe?g|gif|bmp|dib)/i", zip_entry_name($entry))) { - $idata = zip_entry_read($entry, zip_entry_filesize($entry)); - $img = new \Helper\Image(); - $img->load($idata); - break; - } - } - - if(!isset($img)) { - $img = new \Helper\Image("img/mime/base.png"); - } - $img->resize($params["size"], $params["size"]); - } - - // Use generic file icon if type is not supported - else { - $img = new \Helper\Image("img/mime/base.png"); - $img->resize($params["size"], $params["size"]); - } - - // Render file extension over image - if(empty($hide_ext)) { - $ext = strtoupper(pathinfo($file->disk_filename, PATHINFO_EXTENSION)); - $img->text($ext, $params["size"]*0.125, 0, round(0.05 * $params["size"]), round(0.05 * $params["size"]), $bg); - $img->text($ext, $params["size"]*0.125, 0, round(0.05 * $params["size"]) - 1, round(0.05 * $params["size"]) - 1, $fg); - } - - // Render and cache image - $data = $img->dump($params["format"]); - $cache->set($hash, $data, $f3->get("cache_expire.attachments")); - - // Output image - header("Content-type: image/" . $params["format"]); - echo $data; - - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function avatar($f3, $params) { - - // Ensure proper content-type for JPEG images - if($params["format"] == "jpg") { - $params["format"] = "jpeg"; - } - - $user = new \Model\User(); - $user->load($params["id"]); - - if($user->avatar_filename && is_file("uploads/avatars/" . $user->avatar_filename)) { - - // Use local file - $img = new \Image($user->avatar_filename, null, "uploads/avatars/"); - $img->resize($params["size"], $params["size"]); - - // Render and output image - header("Content-type: image/" . $params["format"]); - header("Cache-Control: private, max-age=" . (3600 / 2)); - $img->render($params["format"]); - - } else { - - // Send user to Gravatar - header("Cache-Control: max-age=" . (3600 * 24)); - $f3->reroute($f3->get("SCHEME") . ":" . \Helper\View::instance()->gravatar($user->email, $params["size"]), true); - - } - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function preview($f3, $params) { - $file = new \Model\Issue\File(); - $file->load($params["id"]); - - if(!$file->id || !is_file($file->disk_filename)) { - $f3->error(404); - return; - } - - if(substr($file->content_type, 0, 5) == "image" || $file->content_type == "text/plain") { - $this->_sendFile($file->disk_filename, $file->content_type, null, false); - return; - } - - if($file->content_type == "text/csv" || $file->content_type == "text/tsv") { - $delimiter = ","; - if($file->content_type == "text/tsv") { - $delimiter = "\t"; - } - $f3->set("file", $file); - $f3->set("delimiter", $delimiter); - $this->_render("issues/file/preview/table.html"); - return; - } - - $f3->reroute("/files/{$file->id}/{$file->filename}"); - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function file($f3, $params) { - $file = new \Model\Issue\File(); - $file->load($params["id"]); - - if(!$file->id) { - $f3->error(404); - return; - } - - $force = true; - if(substr($file->content_type, 0, 5) == "image" || - $file->content_type == "text/plain" || - $file->content_type == "application/pdf" - ) { - // Don't force download on image and plain text files - // Eventually I'd like to have previews of files some way (more than - // the existing thumbnails), but for now this is how we do it - Alan - $force = false; - } - - if(!$this->_sendFile($file->disk_filename, $file->content_type, $file->filename, $force)) { - $f3->error(404); - } - } - +class Files extends \Controller +{ + /** + * Require login to access files + */ + public function __construct() + { + $this->_requireLogin(); + } + + /** + * Force the framework to use the local filesystem cache method if possible + */ + protected function _useFileCache() + { + $f3 = \Base::instance(); + $f3->set("CACHE", "folder=" . $f3->get("TEMP") . "cache/"); + } + + /** + * Send a file to the browser + * + * @param string $file + * @param string $mime + * @param string $filename + * @param bool $force + * @return int|bool + */ + protected function _sendFile($file, $mime = "", $filename = "", $force = true) + { + if (!is_file($file)) { + return false; + } + + $size = filesize($file); + + if (!$mime) { + $mime = \Web::instance()->mime($file); + } + header("Content-Type: $mime"); + + if ($force) { + if (!$filename) { + $filename = basename($file); + } + header("Content-Disposition: attachment; filename=\"$filename\""); + } + + header("Accept-Ranges: bytes"); + header("Content-Length: $size"); + header("X-Powered-By: " . \Base::instance()->get("PACKAGE")); + + readfile($file); + return $size; + } + + /** + * GET /files/thumb/@size-@id.@format + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function thumb($f3, $params) + { + $this->_useFileCache(); + $cache = \Cache::instance(); + + // Abort on unusually large dimensions + if ($params["size"] > 1024) { + $f3->error(400); + return; + } + + // Ensure proper content-type for JPEG images + if ($params["format"] == "jpg") { + $params["format"] = "jpeg"; + } + + // Output cached image if one exists + $hash = $f3->hash($f3->get('VERB') . " " . $f3->get('URI')) . ".thm"; + if ($f3->get("DEBUG") < 2) { + if ($cache->exists($hash, $data)) { + header("Content-type: image/" . $params["format"]); + echo $data; + return; + } + } + + $file = new \Model\Issue\File(); + $file->load($params["id"]); + + if (!$file->id) { + $f3->error(404); + return; + } + + // Load 404 image if original file is missing + if (!is_file($file->disk_filename)) { + header("Content-Type: image/svg+xml"); + readfile("img/mime/96/_404.svg"); + return; + } + + // Initialize thumbnail image + $img = new \Image($file->disk_filename); + + // Render thumbnail directly if no alpha + $alpha = (imagecolorat($img->data(), 0, 0) & 0x7F000000) >> 24; + + // 1.1 fits perfectly but crops shadow, so we compare width vs height + $size = intval($params["size"] ?? null) ?: 96; + if ($alpha) { + $thumbSize = $size; + } elseif ($img->width() > $img->height()) { + $thumbSize = round($size / 1.1); + } else { + $thumbSize = round($size / 1.2); + } + + // Resize thumbnail + $img->resize($thumbSize, $thumbSize, false); + $tw = $img->width(); + $th = $img->height(); + $ox = round(($size - $tw) / 2); + $oy = round(($size - $th) / 2); + $fs = round($size / 24); + $fb = round($fs * 0.75); + + // Initialize frame image + $frame = imagecreatetruecolor($size, $size); + imagesavealpha($frame, true); + $transparent = imagecolorallocatealpha($frame, 0, 0, 0, 127); + imagefill($frame, 0, 0, $transparent); + + if (!$alpha) { + // Draw drop shadow + $cs = imagecolorallocatealpha($frame, 0, 0, 0, 120); + imagefilledrectangle($frame, $ox - $fb, $size - $oy + $fb, $size - $ox + $fb, $size - $oy + round($fb * 1.5), $cs); + imagefilledrectangle($frame, $ox - round($fb / 2), $size - $oy + $fb, $size - $ox + round($fb / 2), $size - $oy + round($fb * 1.625), $cs); + imagefilledrectangle($frame, $ox, $size - $oy + $fb, $size - $ox, $size - $oy + round($fb * 2), $cs); + + // Draw frame + $c0 = imagecolorallocatealpha($frame, 193, 193, 193, 16); + imagefilledrectangle($frame, $ox - $fs, $oy - $fs, $size - $ox + $fs, $size - $oy + $fs, $c0); + $c1 = imagecolorallocate($frame, 243, 243, 243); + imagefilledrectangle($frame, $ox - $fb, $oy - $fb, $size - $ox + $fb, $size - $oy + $fb, $c1); + $c2 = imagecolorallocate($frame, 230, 230, 230); + // This is an incredibly stupid way of deprecating a parameter, thanks PHP. + if (PHP_VERSION_ID >= 80000) { + imagefilledpolygon($frame, [ + $size - $ox + $fb, $oy - $fb, + $size - $ox + $fb, $size - $oy + $fb, + $ox - $fb, $size - $oy + $fb, + ], $c2); + } else { + imagefilledpolygon($frame, [ + $size - $ox + $fb, $oy - $fb, + $size - $ox + $fb, $size - $oy + $fb, + $ox - $fb, $size - $oy + $fb, + ], 3, $c2); + } + } + + // Copy thumbnail onto frame + imagecopy($frame, $img->data(), $ox, $oy, 0, 0, $tw, $th); + + if (!$alpha) { + // Draw inner shadow thumbnail + $c3 = imagecolorallocatealpha($frame, 0, 0, 0, 100); + imagerectangle($frame, $ox, $oy, $size - $ox, $size - $oy, $c3); + } + + // Render and cache image + ob_start(); + call_user_func_array('image' . $params["format"], [$frame]); + $data = ob_get_clean(); + if ($f3->get("DEBUG") < 2) { + $cache->set($hash, $data, $f3->get("cache_expire.attachments")); + } + + // Output image + header("Content-type: image/" . $params["format"]); + echo $data; + } + + /** + * GET /avatar/@size-@id.@format + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function avatar($f3, $params) + { + // Ensure proper content-type for JPEG images + if ($params["format"] == "jpg") { + $params["format"] = "jpeg"; + } + + $user = new \Model\User(); + $user->load($params["id"]); + + if ($user->avatar_filename && is_file("uploads/avatars/" . $user->avatar_filename)) { + // Use local file + $img = new \Image($user->avatar_filename, null, "uploads/avatars/"); + $img->resize($params["size"], $params["size"]); + + // Render and output image + header("Content-type: image/" . $params["format"]); + header("Cache-Control: private, max-age=" . (3600 / 2)); + $img->render($params["format"]); + } else { + // Send user to Gravatar + header("Cache-Control: max-age=" . (3600 * 24)); + $f3->reroute($f3->get("SCHEME") . ":" . \Helper\View::instance()->gravatar($user->email, $params["size"]), true); + } + } + + /** + * GET /files/@id/@name + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function file($f3, $params) + { + $file = new \Model\Issue\File(); + $file->load($params["id"]); + + if (!$file->id) { + $f3->error(404); + return; + } + + $force = true; + $type = mime_content_type($file->disk_filename); + if ( + substr($type, 0, 5) == "image" || + $type == "text/plain" || + $type == "application/pdf" || + in_array($type, ['video/mp4', 'video/webm']) + ) { + $force = false; + } + + // Force download of SVG images + if ($type == 'image/svg+xml') { + $force = true; + } + + if (!$this->_sendFile($file->disk_filename, $type, $file->filename, $force)) { + $f3->error(404); + } + } } diff --git a/app/controller/index.php b/app/controller/index.php index 2777c23d..1de26390 100644 --- a/app/controller/index.php +++ b/app/controller/index.php @@ -2,340 +2,377 @@ namespace Controller; -class Index extends \Controller { - - /** - * GET / - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function index($f3, $params) { - if($f3->get("user.id")) { - $user_controller = new \Controller\User(); - return $user_controller->dashboard($f3, $params); - } else { - if($f3->get("site.public")) { - $this->_render("index/index.html"); - } else { - if($f3->get("site.demo") && is_numeric($f3->get("site.demo"))) { - $user = new \Model\User(); - $user->load($f3->get("site.demo")); - if($user->id) { - $session = new \Model\Session($user->id); - $session->setCurrent(); - $f3->set("user", $user->cast()); - $f3->set("user_obj", $user); - $user_controller = new \Controller\User(); - return $user_controller->dashboard($f3, $params); - } else { - $f3->set("error", "Auto-login failed, demo user was not found."); - } - } - $f3->reroute("/login"); - } - } - } - - /** - * GET /login - * - * @param \Base $f3 - */ - public function login($f3) { - if($f3->get("user.id")) { - if(!$f3->get("GET.to")) { - $f3->reroute("/"); - } else { - $f3->reroute($f3->get("GET.to")); - } - } else { - if($f3->get("GET.to")) { - $f3->set("to", $f3->get("GET.to")); - } - $this->_render("index/login.html"); - } - } - - /** - * POST /login - * - * @param \Base $f3 - * @throws \Exception - */ - public function loginpost($f3) { - $user = new \Model\User(); - - // Load user by username or email address - if(strpos($f3->get("POST.username"), "@")) { - $user->load(array("email=? AND deleted_date IS NULL", $f3->get("POST.username"))); - } else { - $user->load(array("username=? AND deleted_date IS NULL", $f3->get("POST.username"))); - } - - // Verify password - $security = \Helper\Security::instance(); - if($security->hash($f3->get("POST.password"), $user->salt ?: "") == $user->password) { - - // Create a session and use it - $session = new \Model\Session($user->id); - $session->setCurrent(); - - if($user->salt) { - if(!$f3->get("POST.to")) { - $f3->reroute("/"); - } else { - $f3->reroute($f3->get("POST.to")); - } - } else { - $f3->set("user", $user->cast()); - $this->_render("index/reset_forced.html"); - } - - } else { - if($f3->get("POST.to")) { - $f3->set("to", $f3->get("POST.to")); - } - $f3->set("login.error", "Invalid login information, try again."); - $this->_render("index/login.html"); - } - } - - /** - * POST /register - * - * @param \Base $f3 - * @throws \Exception - */ - public function registerpost($f3) { - - // Exit immediately if public registrations are disabled - if(!$f3->get("site.public_registration")) { - $f3->error(400); - return; - } - - $errors = array(); - $user = new \Model\User; - - // Check for existing users - $user->load(array("email=?", $f3->get("POST.register-email"))); - if($user->id) { +class Index extends \Controller +{ + /** + * GET / + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function index($f3) + { + if ($f3->get("user.id")) { + $user_controller = new \Controller\User(); + return $user_controller->dashboard($f3); + } else { + if ($f3->get("site.public")) { + $this->_render("index/index.html"); + } else { + if ($f3->get("site.demo") && is_numeric($f3->get("site.demo"))) { + $user = new \Model\User(); + $user->load($f3->get("site.demo")); + if ($user->id) { + $session = new \Model\Session($user->id); + $session->setCurrent(); + $f3->set("user", $user->cast()); + $f3->set("user_obj", $user); + $user_controller = new \Controller\User(); + return $user_controller->dashboard($f3); + } else { + $f3->set("error", "Auto-login failed, demo user was not found."); + } + } + $f3->reroute("/login"); + } + } + } + + /** + * GET /login + * + * @param \Base $f3 + */ + public function login($f3) + { + if ($f3->get("user.id")) { + if (!$f3->get("GET.to")) { + $f3->reroute("/"); + } else { + if (strpos($f3->get("GET.to"), "://") === false || substr($f3->get("GET.to"), 0, 2) == "//") { + $f3->reroute($f3->get("GET.to")); + } else { + $f3->reroute("/"); + } + } + } else { + if ($f3->get("GET.to")) { + $f3->set("to", $f3->get("GET.to")); + } + $this->_render("index/login.html"); + } + } + + /** + * POST /login + * + * @param \Base $f3 + * @throws \Exception + */ + public function loginpost($f3) + { + $this->validateCsrf(); + $user = new \Model\User(); + + // Load user by username or email address + if (strpos($f3->get("POST.username"), "@")) { + $user->load(["email=? AND deleted_date IS NULL", $f3->get("POST.username")]); + } else { + $user->load(["username=? AND deleted_date IS NULL", $f3->get("POST.username")]); + } + + // Verify password + $security = \Helper\Security::instance(); + if ($user->id && hash_equals($security->hash($f3->get("POST.password"), $user->salt ?: ""), $user->password)) { + // Create a session and use it + $session = new \Model\Session($user->id); + $session->setCurrent(); + + if ($user->salt) { + if (!$f3->get("POST.to")) { + $f3->reroute("/"); + } else { + if (strpos($f3->get("POST.to"), "://") === false || substr($f3->get("POST.to"), 0, 2) == "//") { + $f3->reroute($f3->get("POST.to")); + } else { + $f3->reroute("/"); + } + } + } else { + $f3->set("user", $user->cast()); + $this->_render("index/reset_forced.html"); + } + } else { + if ($f3->get("POST.to")) { + $f3->set("to", $f3->get("POST.to")); + } + $f3->set("login.error", "Invalid login information, try again."); + $this->_render("index/login.html"); + } + } + + /** + * POST /register + * + * @param \Base $f3 + * @throws \Exception + */ + public function registerpost($f3) + { + $this->validateCsrf(); + + // Exit immediately if public registrations are disabled + if (!$f3->get("site.public_registration")) { + $f3->error(400); + return; + } + + $errors = []; + $user = new \Model\User(); + + // Check for existing users + $user->load(["email=?", $f3->get("POST.register-email")]); + if ($user->id) { + $user->reset(); + $errors[] = "A user already exists with this email address."; + } + $user->load(["username=?", $f3->get("POST.register-username")]); + if ($user->id) { $user->reset(); - $errors[] = "A user already exists with this email address."; - } - $user->load(array("username=?", $f3->get("POST.register-username"))); - if($user->id) { + $errors[] = "A user already exists with this username."; + } + + // Validate user data + if (!$f3->get("POST.register-name")) { + $errors[] = "Name is required"; + } + if (!preg_match("/^[0-9a-z]{4,}$/i", $f3->get("POST.register-username"))) { + $errors[] = "Usernames must be at least 4 characters and can only contain letters and numbers."; + } + if (!filter_var($f3->get("POST.register-email"), FILTER_VALIDATE_EMAIL)) { + $errors[] = "A valid email address is required."; + } + if (strlen($f3->get("POST.register-password")) < 6) { + $errors[] = "Password must be at least 6 characters."; + } + + // Show errors or create new user + if ($errors) { + $f3->set("register.error", implode("
", $errors)); + $this->_render("index/login.html"); + } else { $user->reset(); - $errors[] = "A user already exists with this username."; - } - - // Validate user data - if(!$f3->get("POST.register-name")) { - $errors[] = "Name is required"; - } - if(!preg_match("/^[0-9a-z]{4,}$/i", $f3->get("POST.register-username"))) { - $errors[] = "Usernames must be at least 4 characters and can only contain letters and numbers."; - } - if(!filter_var($f3->get("POST.register-email"), FILTER_VALIDATE_EMAIL)) { - $errors[] = "A valid email address is required."; - } - if(strlen($f3->get("POST.register-password")) < 6) { - $errors[] = "Password must be at least 6 characters."; - } - - // Show errors or create new user - if($errors) { - $f3->set("register.error", implode("
", $errors)); - $this->_render("index/login.html"); - } else { - $user->reset(); - $user->username = trim($f3->get("POST.register-username")); - $user->email = trim($f3->get("POST.register-email")); - $user->name = trim($f3->get("POST.register-name")); - $security = \Helper\Security::instance(); - extract($security->hash($f3->get("POST.register-password"))); - $user->password = $hash; - $user->salt = $salt; - $user->task_color = sprintf("#%02X%02X%02X", mt_rand(0, 0xFF), mt_rand(0, 0xFF), mt_rand(0, 0xFF)); - $user->rank = \Model\User::RANK_CLIENT; - $user->save(); - - // Create a session and use it - $session = new \Model\Session($user->id); - $session->setCurrent(); - - $f3->reroute("/"); - } - } - - /** - * GET|POST /reset - * - * @param \Base $f3 - * @throws \Exception - */ - public function reset($f3) { - if($f3->get("user.id")) { - $f3->reroute("/"); - } else { - if($f3->get("POST.email")) { - $user = new \Model\User; - $user->load(array("email = ?", $f3->get("POST.email"))); - if($user->id && !$user->deleted_date) { - $notification = \Helper\Notification::instance(); - $notification->user_reset($user->id); - $f3->set("reset.success", "We've sent an email to " . $f3->get("POST.email") . " with a link to reset your password."); - } else { - $f3->set("reset.error", "No user exists with the email address " . $f3->get("POST.email") . "."); - } - } - unset($user); - $this->_render("index/reset.html"); - } - } - - /** - * GET|POST /reset/@hash - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function reset_complete($f3, $params) { - if($f3->get("user.id")) { - $f3->reroute("/"); - } else { - $user = new \Model\User; - $user->load(array("CONCAT(password, salt) = ?", $params["hash"])); - if(!$user->id || !$params["hash"]) { - $f3->set("reset.error", "Invalid reset URL."); - $this->_render("index/reset.html"); - return; - } - if($f3->get("POST.password1")) { - // Validate new password - if($f3->get("POST.password1") != $f3->get("POST.password2")) { - $f3->set("reset.error", "The given passwords don't match."); - } elseif(strlen($f3->get("POST.password1")) < 6) { - $f3->set("reset.error", "The given password is too short. Passwords must be at least 6 characters."); - } else { - // Save new password and redirect to login - $security = \Helper\Security::instance(); - $user->salt = $security->salt(); - $user->password = $security->hash($f3->get("POST.password1"), $user->salt); - $user->save(); - $f3->reroute("/login"); - return; - } - } - $f3->set("resetuser", $user); - $this->_render("index/reset_complete.html"); - } - } - - /** - * GET|POST /reset/forced - * - * @param \Base $f3 - */ - public function reset_forced($f3) { - $user = new \Model\User; - $user->loadCurrent(); - - if($f3->get("POST.password1") != $f3->get("POST.password2")) { - $f3->set("reset.error", "The given passwords don't match."); - } elseif(strlen($f3->get("POST.password1")) < 6) { - $f3->set("reset.error", "The given password is too short. Passwords must be at least 6 characters."); - } else { - // Save new password and redirect to dashboard - $security = \Helper\Security::instance(); - $user->salt = $security->salt(); - $user->password = $security->hash($f3->get("POST.password1"), $user->salt); - $user->save(); - $f3->reroute("/"); - return; - } - $this->_render("index/reset_forced.html"); - } - - /** - * GET|POST /logout - * - * @param \Base $f3 - */ - public function logout($f3) { - $session = new \Model\Session; - $session->loadCurrent(); - $session->delete(); - $f3->reroute("/"); - } - - /** - * GET|POST /ping - * - * @param \Base $f3 - */ - public function ping($f3) { - if($f3->get("user.id")) { - $this->_printJson(array("user_id" => $f3->get("user.id"), "is_logged_in" => true)); - } else { - $this->_printJson(array("user_id" => null, "is_logged_in" => false)); - } - } - - /** - * GET /atom.xml - * - * @param \Base $f3 - * @throws \Exception - */ - public function atom($f3) { - // Authenticate user - if($f3->get("GET.key")) { - $user = new \Model\User; - $user->load(array("api_key = ?", $f3->get("GET.key"))); - if(!$user->id) { - $f3->error(403); - return; - } - } else { - $f3->error(403); - return; - } - - // Get requested array substituting defaults - $get = $f3->get("GET") + array("type" => "assigned", "user" => $user->username); - unset($user); - - // Load target user - $user = new \Model\User; - $user->load(array("username = ?", $get["user"])); - if(!$user->id) { - $f3->error(404); - return; - } - - // Load issues - $issue = new \Model\Issue\Detail; - $options = array("order" => "created_date DESC"); - if($get["type"] == "assigned") { - $issues = $issue->find(array("author_id = ? AND status_closed = 0 AND deleted_date IS NULL", $user->id), $options); - } elseif($get["type"] == "created") { - $issues = $issue->find(array("owner = ? AND status_closed = 0 AND deleted_date IS NULL", $user->id), $options); - } elseif($get["type"] == "all") { - $issues = $issue->find("status_closed = 0 AND deleted_date IS NULL", $options + array("limit" => 50)); - } else { - $f3->error(400, "Invalid feed type"); - return; - } - - // Render feed - $f3->set("get", $get); - $f3->set("feed_user", $user); - $f3->set("issues", $issues); - $this->_render("index/atom.xml", "application/atom+xml"); - } + $user->username = trim($f3->get("POST.register-username")); + $user->email = trim($f3->get("POST.register-email")); + $user->name = trim($f3->get("POST.register-name")); + $security = \Helper\Security::instance(); + $hash = $security->hash($f3->get("POST.register-password")); + extract($hash); + $user->password = $hash; + $user->salt = $salt; + $user->task_color = sprintf("%02X%02X%02X", random_int(0, 0xFF), random_int(0, 0xFF), random_int(0, 0xFF)); + $user->rank = \Model\User::RANK_CLIENT; + $user->save(); + + // Create a session and use it + $session = new \Model\Session($user->id); + $session->setCurrent(); + + $f3->reroute("/"); + } + } + + /** + * GET|POST /reset + * + * @param \Base $f3 + * @throws \Exception + */ + public function reset($f3) + { + if ($f3->get("user.id")) { + $f3->reroute("/"); + } else { + if ($f3->get("POST.email")) { + $this->validateCsrf(); + $user = new \Model\User(); + $user->load(["email = ?", $f3->get("POST.email")]); + if ($user->id && !$user->deleted_date) { + // Re-generate reset token + $token = $user->generateResetToken(); + $user->save(); + + // Send notification + $notification = \Helper\Notification::instance(); + $notification->user_reset($user->id, $token); + + $f3->set("reset.success", "We've sent an email to " . $f3->get("POST.email") . " with a link to reset your password."); + } else { + $f3->set("reset.error", "No user exists with the email address " . $f3->get("POST.email") . "."); + } + } + unset($user); + $this->_render("index/reset.html"); + } + } + + /** + * GET|POST /reset/@token + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function reset_complete($f3, $params) + { + if ($f3->get("user.id")) { + $f3->reroute("/"); + return; + } + + if (!$params["token"]) { + $f3->reroute("/login"); + return; + } + + $user = new \Model\User(); + $user->load(["reset_token = ?", hash("sha384", $params["token"])]); + if (!$user->id || !$user->validateResetToken($params["token"])) { + $f3->set("reset.error", "Invalid reset URL."); + $this->_render("index/reset.html"); + return; + } + + if ($f3->get("POST.password1")) { + $this->validateCsrf(); + + // Validate new password + if ($f3->get("POST.password1") != $f3->get("POST.password2")) { + $f3->set("reset.error", "The given passwords don't match."); + } elseif (strlen($f3->get("POST.password1")) < 6) { + $f3->set("reset.error", "The given password is too short. Passwords must be at least 6 characters."); + } else { + // Save new password and redirect to login + $security = \Helper\Security::instance(); + $user->reset_token = null; + $user->salt = $security->salt(); + $user->password = $security->hash($f3->get("POST.password1"), $user->salt); + $user->save(); + $f3->reroute("/login"); + return; + } + } + $f3->set("resetuser", $user); + $this->_render("index/reset_complete.html"); + } + + /** + * GET|POST /reset/forced + * + * @param \Base $f3 + */ + public function reset_forced($f3) + { + $user = new \Model\User(); + $user->loadCurrent(); + + if ($f3->get('POST')) { + $this->validateCsrf(); + } + + if ($f3->get("POST.password1") != $f3->get("POST.password2")) { + $f3->set("reset.error", "The given passwords don't match."); + } elseif (strlen($f3->get("POST.password1")) < 6) { + $f3->set("reset.error", "The given password is too short. Passwords must be at least 6 characters."); + } else { + // Save new password and redirect to dashboard + $security = \Helper\Security::instance(); + $user->salt = $security->salt(); + $user->password = $security->hash($f3->get("POST.password1"), $user->salt); + $user->save(); + $f3->reroute("/"); + return; + } + $this->_render("index/reset_forced.html"); + } + + /** + * POST /logout + * + * @param \Base $f3 + */ + public function logout($f3) + { + $this->validateCsrf(); + $session = new \Model\Session(); + $session->loadCurrent(); + $session->delete(); + $f3->reroute("/"); + } + + /** + * GET /atom.xml + * + * @param \Base $f3 + * @throws \Exception + */ + public function atom($f3) + { + // Authenticate user + if ($f3->get("GET.key")) { + $user = new \Model\User(); + $user->load(["api_key = ?", $f3->get("GET.key")]); + if (!$user->id) { + $f3->error(403); + return; + } + } else { + $f3->error(403); + return; + } + + // Get requested array substituting defaults + $get = $f3->get("GET") + ["type" => "assigned", "user" => $user->username]; + unset($user); + + // Load target user + $user = new \Model\User(); + $user->load(["username = ?", $get["user"]]); + if (!$user->id) { + $f3->error(404); + return; + } + + // Load issues + $issue = new \Model\Issue\Detail(); + $options = ["order" => "created_date DESC"]; + if ($get["type"] == "assigned") { + $issues = $issue->find(["author_id = ? AND status_closed = 0 AND deleted_date IS NULL", $user->id], $options); + } elseif ($get["type"] == "created") { + $issues = $issue->find(["owner_id = ? AND status_closed = 0 AND deleted_date IS NULL", $user->id], $options); + } elseif ($get["type"] == "all") { + $issues = $issue->find("status_closed = 0 AND deleted_date IS NULL", $options + ["limit" => 50]); + } else { + $f3->error(400, "Invalid feed type"); + return; + } + + // Render feed + $f3->set("get", $get); + $f3->set("feed_user", $user); + $f3->set("issues", $issues); + $this->_render("index/atom.xml", "application/atom+xml"); + } + /** + * GET /opensearch.xml + * + * @param \Base $f3 + * @throws \Exception + */ + public function opensearch($f3) + { + $this->_render("index/opensearch.xml", "application/opensearchdescription+xml"); + } } diff --git a/app/controller/issues.php b/app/controller/issues.php index 539697b1..c083ad52 100644 --- a/app/controller/issues.php +++ b/app/controller/issues.php @@ -2,1337 +2,1436 @@ namespace Controller; -class Issues extends \Controller { - - protected $_userId; - - /** - * Require login on new - */ - public function __construct() { - $this->_userId = $this->_requireLogin(); - } - - /** - * Clean a string for encoding in JSON - * Collapses whitespace, then trims - * - * @param string $string - * @return string - */ - protected function _cleanJson($string) { - return trim(preg_replace('/\s+/', ' ', $string)); - } - - /** - * Build a WHERE clause for issue listings based on the current filters and sort options - * - * @return array - */ - protected function _buildFilter() { - $f3 = \Base::instance(); - $db = $f3->get("db.instance"); - $issues = new \Model\Issue\Detail; - - // Filter issue listing by URL parameters - $filter = array(); - $args = $f3->get("GET"); - foreach($args as $key => $val) { - if(!empty($val) && !is_array($val) && $issues->exists($key)) { - $filter[$key] = $val; - } - } - unset($val); - - // Build SQL string to use for filtering - $filter_str = ""; - foreach($filter as $i => $val) { - if($i == "name") { - $filter_str .= "`$i` LIKE " . $db->quote("%$val%") . " AND "; - } elseif($i == "status" && $val == "open") { - $filter_str .= "status_closed = 0 AND "; - } elseif($i == "status" && $val == "closed") { - $filter_str .= "status_closed = 1 AND "; - } elseif($i == "repeat_cycle" && $val == "repeat") { - $filter_str .= "repeat_cycle IS NOT NULL AND "; - } elseif($i == "repeat_cycle" && $val == "none") { - $filter_str .= "repeat_cycle IS NULL AND "; - } elseif(($i == "author_id" || $i== "owner_id") && !empty($val) && is_numeric($val)) { - // Find all users in a group if necessary - $user = new \Model\User; - $user->load($val); - if($user->role == 'group') { - $group_users = new \Model\User\Group; - $list = $group_users->find(array('group_id = ?', $val)); - $garray = array($val); // Include the group in the search - foreach ($list as $obj) { - $garray[] = $obj->user_id; - } - $filter_str .= "$i in (". implode(",",$garray) .") AND "; - } else { - // Just select by user - $filter_str .= "$i = ". $db->quote($val) ." AND "; - } - } else { - $filter_str .= "`$i` = " . $db->quote($val) . " AND "; - } - } - unset($val); - $filter_str .= " deleted_date IS NULL "; - - // Build SQL ORDER BY string - $orderby = !empty($args['orderby']) ? $args['orderby'] : "priority"; - $filter["orderby"] = $orderby; - $ascdesc = !empty($args['ascdesc']) && strtolower($args['ascdesc']) == 'asc' ? "ASC" : "DESC"; - $filter["ascdesc"] = $ascdesc; - switch($orderby) { - case "id": - $filter_str .= " ORDER BY id {$ascdesc} "; - break; - case "title": - $filter_str .= " ORDER BY name {$ascdesc}"; - break; - case "type": - $filter_str .= " ORDER BY type_id {$ascdesc}, priority DESC, due_date DESC "; - break; - case "status": - $filter_str .= " ORDER BY status {$ascdesc}, priority DESC, due_date DESC "; - break; - case "parent_id": - $filter_str .= " ORDER BY parent_id {$ascdesc}, priority DESC, due_date DESC "; - break; - case "author": - $filter_str .= " ORDER BY author_name {$ascdesc}, priority DESC, due_date DESC "; - break; - case "assignee": - $filter_str .= " ORDER BY owner_name {$ascdesc}, priority DESC, due_date DESC "; - break; - case "created": - $filter_str .= " ORDER BY created_date {$ascdesc}, priority DESC, due_date DESC "; - break; - case "due": - $filter_str .= " ORDER BY due_date {$ascdesc}, priority DESC"; - break; - case "sprint": - $filter_str .= " ORDER BY sprint_start_date {$ascdesc}, priority DESC, due_date DESC "; - break; - case "closed": - $filter_str .= " ORDER BY closed_date {$ascdesc}, priority DESC, due_date DESC "; - break; - case "priority": - default: - $filter_str .= " ORDER BY priority {$ascdesc}, due_date DESC "; - break; - } - - return array($filter, $filter_str); - - } - - /** - * GET /issues - * Display a sortable, filterable issue list - * - * @param \Base $f3 - */ - public function index($f3) { - $issues = new \Model\Issue\Detail; - - // Get filter - $args = $f3->get("GET"); - list($filter, $filter_str) = $this->_buildFilter(); - - // Load type if a type_id was passed - $type = new \Model\Issue\Type; - if(!empty($args["type_id"])) { - $type->load($args["type_id"]); - if($type->id) { - $f3->set("title", $f3->get("dict.issues") . " - " . $f3->get("dict.by_type") . ": " . $type->name); - $f3->set("type", $type); - } - } else { - $f3->set("title", $f3->get("dict.issues")); - } - - $status = new \Model\Issue\Status; - $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); - - $priority = new \Model\Issue\Priority; - $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); - - $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); - - $sprint = new \Model\Sprint; - $f3->set("sprints", $sprint->find(array("end_date >= ?", date("Y-m-d")), array("order" => "start_date ASC"))); - $f3->set("old_sprints", $sprint->find(array("end_date < ?", date("Y-m-d")), array("order" => "start_date ASC"))); - - $users = new \Model\User; - $f3->set("users", $users->getAll()); - $f3->set("deleted_users", $users->getAllDeleted()); - $f3->set("groups", $users->getAllGroups()); - - if(empty($args["page"])) { - $args["page"] = 0; - } - $issue_page = $issues->paginate($args["page"], 50, $filter_str); - $f3->set("issues", $issue_page); - - // Pass filter string for pagination - $filter_get = http_build_query($filter); - - if(!empty($orderby)) { - $filter_get .= "&orderby=" . $orderby; - } - if($issue_page["count"] > 7) { - if($issue_page["pos"] <= 3) { - $min = 0; - } else { - $min = $issue_page["pos"] - 3; - } - if($issue_page["pos"] < $issue_page["count"] - 3) { - $max = $issue_page["pos"] + 3; - } else { - $max = $issue_page["count"] - 1; - } - } else { - $min = 0; - $max = $issue_page["count"] - 1; - } - $f3->set("pages", range($min, $max)); - $f3->set("filter_get", $filter_get); - - $f3->set("menuitem", "browse"); - $f3->set("heading_links_enabled", true); - - $f3->set("show_filters", true); - $f3->set("show_export", true); - - $this->_render("issues/index.html"); - } - - /** - * POST /issues/bulk_update - * Update a list of issues - * - * @param \Base $f3 - */ - public function bulk_update($f3) { - $post = $f3->get("POST"); - - $issue = new \Model\Issue; - if(!empty($post["id"]) && is_array($post["id"] )) { - foreach($post["id"] as $id) { - // Updating existing issue. - $issue->load($id); - if($issue->id) { - - if(!empty($post["update_copy"])) { - $issue = $issue->duplicate(false); - } - - // Diff contents and save what's changed. - foreach($post as $i=>$val) { - if( - $issue->exists($i) - && $i != "id" - && $issue->$i != $val - && (!empty($val) || $val === "0") - ) { - // Allow setting to Not Assigned - if(($i == "owner_id" || $i == "sprint_id") && $val == -1) { - $val = null; - } - $issue->$i = $val; - if($i == "status") { - $status = new \Model\Issue\Status; - $status->load($val); - - // Toggle closed_date if issue has been closed/restored - if($status->closed) { - if(!$issue->closed_date) { - $issue->closed_date = $this->now(); - } - } else { - $issue->closed_date = null; - } - } - } - } - - // Save to the sprint of the due date if no sprint selected - if (!empty($post['due_date']) && empty($post['sprint_id'])) { - $sprint = new \Model\Sprint; - $sprint->load(array("DATE(?) BETWEEN start_date AND end_date",$issue->due_date)); - $issue->sprint_id = $sprint->id; - } - - // If it's a child issue and the parent is in a sprint, assign to that sprint - if(!empty($post['bulk']['parent_id']) && !$issue->sprint_id) { - $parent = new \Model\Issue; - $parent->load($issue->parent_id); - if($parent->sprint_id) { - $issue->sprint_id = $parent->sprint_id; - } - } - - $notify = !empty($post["notify"]); - $issue->save($notify); - - } else { - $f3->error(500, "Failed to update all the issues, starting with: $id."); - return; - } - } - - } else { - $f3->reroute($post["url_path"] . "?" . $post["url_query"]); - } - - if (!empty($post["url_path"])) { - $f3->reroute($post["url_path"] . "?" . $post["url_query"]); - } else { - $f3->reroute("/issues?" . $post["url_query"]); - } - } - - /** - * GET /issues/export - * Export a list of issues - * - * @param \Base $f3 - */ - public function export($f3) { - $issue = new \Model\Issue\Detail; - - // Get filter data and load issues - $filter = $this->_buildFilter(); - $issues = $issue->find($filter[1]); - - // Configure visible fields - $fields = array( - "id" => $f3->get("dict.cols.id"), - "name" => $f3->get("dict.cols.title"), - "type_name" => $f3->get("dict.cols.type"), - "priority_name" => $f3->get("dict.cols.priority"), - "status_name" => $f3->get("dict.cols.status"), - "author_name" => $f3->get("dict.cols.author"), - "owner_name" => $f3->get("dict.cols.assignee"), - "sprint_name" => $f3->get("dict.cols.sprint"), - "created_date" => $f3->get("dict.cols.created"), - "due_date" => $f3->get("dict.cols.due_date"), - ); - - // Notify browser that file is a CSV, send as attachment (force download) - header("Content-type: text/csv"); - header("Content-Disposition: attachment; filename=issues-" . time() . ".csv"); - header("Pragma: no-cache"); - header("Expires: 0"); - - // Output data directly - $fh = fopen("php://output", "w"); - - // Add column headings - fwrite($fh, '"' . implode('","', array_values($fields)) . "\"\n"); - - // Add rows - foreach($issues as $row) { - $cols = array(); - foreach(array_keys($fields) as $field) { - $cols[] = $row->get($field); - } - fputcsv($fh, $cols); - } - - fclose($fh); - } - - /** - * Get /issues/new/@type - * Create a new issue - * - * @param \Base $f3 - */ - public function add($f3) { - if($f3->get("PARAMS.type")) { - $type_id = $f3->get("PARAMS.type"); - } else { - $type_id = 1; - } - - $type = new \Model\Issue\Type; - $type->load($type_id); - - if(!$type->id) { - $f3->error(404, "Issue type does not exist"); - return; - } - - if($f3->get("PARAMS.parent")) { - $parent_id = $f3->get("PARAMS.parent"); - $parent = new \Model\Issue; - $parent->load(array("id = ?", $parent_id)); - if($parent->id) { - $f3->set("parent", $parent); - } - } - - $status = new \Model\Issue\Status; - $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); - - $priority = new \Model\Issue\Priority; - $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); - - $sprint = new \Model\Sprint; - $f3->set("sprints", $sprint->find(array("end_date >= ?", $this->now(false)), array("order" => "start_date ASC"))); - - $users = new \Model\User; - $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - $f3->set("groups", $users->find("deleted_date IS NULL AND role = 'group'", array("order" => "name ASC"))); - - $f3->set("title", $f3->get("dict.new_n", $type->name)); - $f3->set("menuitem", "new"); - $f3->set("type", $type); - - $this->_render("issues/edit.html"); - } - - /** - * @param \Base $f3 - */ - public function add_selecttype($f3) { - $type = new \Model\Issue\Type; - $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); - - $f3->set("title", $f3->get("dict.new_n", $f3->get("dict.issues"))); - $f3->set("menuitem", "new"); - $this->_render("issues/new.html"); - } - - /** - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function edit($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - $type = new \Model\Issue\Type; - $type->load($issue->type_id); - - $status = new \Model\Issue\Status; - $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); - - $priority = new \Model\Issue\Priority; - $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); - - $sprint = new \Model\Sprint; - $f3->set("sprints", $sprint->find(array("end_date >= ? OR id = ?", $this->now(false), $issue->sprint_id), array("order" => "start_date ASC"))); - - $users = new \Model\User; - $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - $f3->set("groups", $users->find("deleted_date IS NULL AND role = 'group'", array("order" => "name ASC"))); - - $f3->set("title", $f3->get("edit_n", $issue->id)); - $f3->set("issue", $issue); - $f3->set("type", $type); - - if($f3->get("AJAX")) { - $this->_render("issues/edit-form.html"); - } else { - $this->_render("issues/edit.html"); - } - } - - /** - * GET /issues/close/@id - * Close an issue - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function close($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - $issue->close(); - - $f3->reroute("/issues/" . $issue->id); - } - - /** - * GET /issues/reopen/@id - * Reopen an issue - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function reopen($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - if($issue->closed_date) { - $status = new \Model\Issue\Status; - $status->load(array("closed = ?", 0)); - $issue->status = $status->id; - $issue->closed_date = null; - $issue->save(); - } - - $f3->reroute("/issues/" . $issue->id); - } - - /** - * GET /issues/copy/@id - * Copy an issue - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function copy($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - $new_issue = $issue->duplicate(); - - if($new_issue->id) { - $f3->reroute("/issues/" . $new_issue->id); - } else { - $f3->error(500, "Failed to duplicate issue."); - } - - } - - /** - * Save an updated issue - * - * @return \Model\Issue - */ - protected function _saveUpdate() { - $f3 = \Base::instance(); - $post = array_map("trim", $f3->get("POST")); - $issue = new \Model\Issue; - - // Load issue and return if not set - $issue->load($post["id"]); - if(!$issue->id) { - return $issue; - } - - // Diff contents and save what's changed. - $hashState = json_decode($post["hash_state"]); - foreach($post as $i=>$val) { - if( - $issue->exists($i) - && $i != "id" - && $issue->$i != $val - && md5($val) != $hashState->$i - ) { - if(empty($val)) { - $issue->$i = null; - } else { - $issue->$i = $val; - - if($i == "status") { - $status = new \Model\Issue\Status; - $status->load($val); - - // Toggle closed_date if issue has been closed/restored - if($status->closed) { - if(!$issue->closed_date) { - $issue->closed_date = $this->now(); - } - } else { - $issue->closed_date = null; - } - } - - // Save to the sprint of the due date unless one already set - if ($i=="due_date" && !empty($val)) { - if(empty($post['sprint_id'])) { - $sprint = new \Model\Sprint; - $sprint->load(array("DATE(?) BETWEEN start_date AND end_date",$val)); - $issue->sprint_id = $sprint->id; - } - } - } - } - } - - // Save comment if given - if(!empty($post["comment"])) { - $comment = new \Model\Issue\Comment; - $comment->user_id = $this->_userId; - $comment->issue_id = $issue->id; - $comment->text = $post["comment"]; - $comment->created_date = $this->now(); - $comment->save(); - $f3->set("update_comment", $comment); - } - - // Save issue, optionally send notifications - $notify = !empty($post["notify"]); - $issue->save($notify); - - return $issue; - } - - /** - * Create a new issue from $_POST - * - * @return \Model\Issue - */ - protected function _saveNew() { - $f3 = \Base::instance(); - return \Model\Issue::create($f3->get("POST"), !!$f3->get("POST.notify")); - } - - /** - * POST /issues - * Save an issue - * - * @param \Base $f3 - */ - public function save($f3) { - if($f3->get("POST.id")) { - - // Updating existing issue. - $issue = $this->_saveUpdate(); - if($issue->id) { - $f3->reroute("/issues/" . $issue->id); - } else { - $f3->error(404, "This issue does not exist."); - } - - } elseif($f3->get("POST.name")) { - - // Creating new issue. - $issue = $this->_saveNew(); - if($issue->id) { - $f3->reroute("/issues/" . $issue->id); - } else { - $f3->error(500, "An error occurred saving the issue."); - } - - } else { - $f3->reroute("/issues/new/" . $f3->get("POST.type_id")); - } - } - - /** - * GET /issues/@id - * View an issue - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single($f3, $params) { - $issue = new \Model\Issue\Detail; - $issue->load(array("id=?", $params["id"])); - $user = $f3->get("user_obj"); - - if(!$issue->id || ($issue->deleted_date && !($user->role == 'admin' || $user->rank >= \Model\User::RANK_MANAGER || $issue->author_id == $user->id))) { - $f3->error(404); - return; - } - - $type = new \Model\Issue\Type(); - $type->load($issue->type_id); - - $f3->set("title", $type->name . " #" . $issue->id . ": " . $issue->name); - $f3->set("menuitem", "browse"); - - $author = new \Model\User(); - $author->load($issue->author_id); - $owner = new \Model\User(); - if($issue->owner_id) { - $owner->load($issue->owner_id); - } - - $files = new \Model\Issue\File\Detail; - $f3->set("files", $files->find(array("issue_id = ? AND deleted_date IS NULL", $issue->id))); - - if($issue->sprint_id) { - $sprint = new \Model\Sprint(); - $sprint->load($issue->sprint_id); - $f3->set("sprint", $sprint); - } - - $watching = new \Model\Issue\Watcher; - $watching->load(array("issue_id = ? AND user_id = ?", $issue->id, $this->_userId)); - $f3->set("watching", !!$watching->id); - - $f3->set("issue", $issue); - $f3->set("ancestors", $issue->getAncestors()); - $f3->set("type", $type); - $f3->set("author", $author); - $f3->set("owner", $owner); - - $comments = new \Model\Issue\Comment\Detail; - $f3->set("comments", $comments->find(array("issue_id = ?", $issue->id), array("order" => "created_date DESC"))); - - // Extra data needed for inline edit form - $status = new \Model\Issue\Status; - $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); - - $priority = new \Model\Issue\Priority; - $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); - - $sprint = new \Model\Sprint; - $f3->set("sprints", $sprint->find(array("end_date >= ? OR id = ?", $this->now(false), $issue->sprint_id), array("order" => "start_date ASC"))); - - $users = new \Model\User; - $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - $f3->set("groups", $users->find("deleted_date IS NULL AND role = 'group'", array("order" => "name ASC"))); - - $this->_render("issues/single.html"); - } - - /** - * POST /issues/@id/watchers - * Add a watcher - * - * @param \Base $f3 - * @param array $params - */ - public function add_watcher($f3, $params) { - $issue = new \Model\Issue; - $issue->load(array("id=?", $params["id"])); - if(!$issue->id) { - $f3->error(404); - } - - $watcher = new \Model\Issue\Watcher; - - // Loads just in case the user is already a watcher - $watcher->load(array("issue_id = ? AND user_id = ?", $issue->id, $post["user_id"])); - if(!$watcher->id) { - $watcher->issue_id = $issue->id; - $watcher->user_id = $f3->get("POST.user_id"); - $watcher->save(); - } - } - - /** - * POST /issues/@id/watchers/delete - * Delete a watcher - * - * @param \Base $f3 - * @param array $params - */ - public function delete_watcher($f3, $params) { - $issue = new \Model\Issue; - $issue->load(array("id=?", $params["id"])); - if(!$issue->id) { - $f3->error(404); - } - - $watcher = new \Model\Issue\Watcher; - - $watcher->load(array("issue_id = ? AND user_id = ?", $issue->id, $f3->get("POST.user_id"))); - $watcher->delete(); - } - - /** - * POST /issues/@id/dependencies - * Add a dependency - * - * @param \Base $f3 - * @param array $params - */ - public function add_dependency($f3, $params) { - $issue = new \Model\Issue; - $issue->load(array("id=?", $params["id"])); - if(!$issue->id) { - $f3->error(404); - } - - $dependency = new \Model\Issue\Dependency; - - // Loads just in case the task is already a dependency - $dependency->load(array("issue_id = ? AND dependency_id = ?", $issue->id, $f3->get("POST.id"))); - $dependency->issue_id = $f3->get("POST.issue_id"); - $dependency->dependency_id = $f3->get("POST.dependency_id"); - $dependency->dependency_type = $f3->get("POST.type"); - $dependency->save(); - } - - /** - * POST /issues/@id/dependencies/delete - * Delete a dependency - * - * @param \Base $f3 - * @param array $params - */ - public function delete_dependency($f3, $params) { - $issue = new \Model\Issue; - $issue->load(array("id=?", $params["id"])); - if(!$issue->id) { - $f3->error(404); - } - - $dependency = new \Model\Issue\Dependency; - $dependency->load($f3->get("POST.id")); - $dependency->delete(); - } - - /** - * GET /issues/@id/history - * AJAX call for issue history - * - * @param \Base $f3 - * @param array $params - */ - public function single_history($f3, $params) { - // Build updates array - $updates_array = array(); - $update_model = new \Model\Custom("issue_update_detail"); - $updates = $update_model->find(array("issue_id = ?", $params["id"]), array("order" => "created_date DESC")); - foreach($updates as $update) { - $update_array = $update->cast(); - $update_field_model = new \Model\Issue\Update\Field; - $update_array["changes"] = $update_field_model->find(array("issue_update_id = ?", $update["id"])); - $updates_array[] = $update_array; - } - - $f3->set("updates", $updates_array); - - $this->_printJson(array( - "total" => count($updates), - "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/history.html")) - )); - } - - /** - * GET /issues/@id/related - * AJAX call for related issues - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single_related($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if($issue->id) { - $f3->set("parent", $issue); - - $issues = new \Model\Issue\Detail; - if($exclude = $f3->get("GET.exclude")) { - $searchparams = array("parent_id = ? AND id != ? AND deleted_date IS NULL", $issue->id, $exclude); - } else { - $searchparams = array("parent_id = ? AND deleted_date IS NULL", $issue->id); - } - $orderparams = array("order" => "status_closed, priority DESC, due_date"); - $f3->set("issues", $issues->find($searchparams, $orderparams)); - - $searchparams[0] = $searchparams[0] . " AND status_closed = 0"; - $openissues = $issues->count($searchparams); - - $this->_printJson(array( - "total" => count($f3->get("issues")), - "open" => $openissues, - "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/related.html")) - )); - } else { - $f3->error(404); - } - } - - /** - * GET /issues/@id/watchers - * AJAX call for issue watchers - * - * @param \Base $f3 - * @param array $params - */ - public function single_watchers($f3, $params) { - $watchers = new \Model\Custom("issue_watcher_user"); - $f3->set("watchers", $watchers->find(array("issue_id = ?", $params["id"]))); - $users = new \Model\User; - $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - - $this->_printJson(array( - "total" => count($f3->get("watchers")), - "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/watchers.html")) - )); - } - - /** - * GET /issues/@id/dependencies - * AJAX call for issue dependencies - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single_dependencies($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if($issue->id) { - $f3->set("issue", $issue); - $dependencies = new \Model\Issue\Dependency; - $f3->set("dependencies", $dependencies->findby_issue($issue->id)); - $f3->set("dependents", $dependencies->findby_dependent($issue->id)); - - $this->_printJson(array( - "total" => count($f3->get("dependencies")) + count($f3->get("dependents")), - "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/dependencies.html")) - )); - } else { - $f3->error(404); - } - } - - /** - * POST /issues/delete/@id - * Delete an issue - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single_delete($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - $user = $f3->get("user_obj"); - if($user->role == "admin" || $user->rank >= \Model\User::RANK_MANAGER || $issue->author_id == $user->id) { - $issue->delete(); - $f3->reroute("/issues/{$issue->id}"); - } else { - $f3->error(403); - } - } - - /** - * POST /issues/undelete/@id - * Un-delete an issue - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single_undelete($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - $user = $f3->get("user_obj"); - if($user->role == "admin" || $user->rank >= \Model\User::RANK_MANAGER || $issue->author_id == $user->id) { - $issue->restore(); - $f3->reroute("/issues/{$issue->id}"); - } else { - $f3->error(403); - } - } - - /** - * POST /issues/comment/save - * Save a comment - * - * @param \Base $f3 - * @throws \Exception - */ - public function comment_save($f3) { - $post = $f3->get("POST"); - - $issue = new \Model\Issue; - $issue->load($post["issue_id"]); - - if(!$issue->id || empty($post["text"])) { - if($f3->get("AJAX")) { - $this->_printJson(array("error" => 1)); - } else { - $f3->reroute("/issues/" . $post["issue_id"]); - } - return; - } - - if($f3->get("POST.action") == "close") { - $issue->close(); - } - - $comment = \Model\Issue\Comment::create(array( - "issue_id" => $post["issue_id"], - "user_id" => $this->_userId, - "text" => trim($post["text"]) - ), !!$f3->get("POST.notify")); - - if($f3->get("AJAX")) { - $this->_printJson( - array( - "id" => $comment->id, - "text" => \Helper\View::instance()->parseText($comment->text, array("hashtags" => false)), - "date_formatted" => date("D, M j, Y \\a\\t g:ia", \Helper\View::instance()->utc2local(time())), - "user_name" => $f3->get('user.name'), - "user_username" => $f3->get('user.username'), - "user_email" => $f3->get('user.email'), - "user_email_md5" => md5(strtolower($f3->get('user.email'))), - ) - ); - return; - } else { - $f3->reroute("/issues/" . $comment->issue_id); - } - } - - /** - * POST /issues/comment/delete - * Delete a comment - * - * @param \Base $f3 - * @throws \Exception - */ - public function comment_delete($f3) { - $this->_requireAdmin(); - $comment = new \Model\Issue\Comment; - $comment->load($f3->get("POST.id")); - $comment->delete(); - $this->_printJson(array("id" => $f3->get("POST.id")) + $comment->cast()); - } - - /** - * POST /issues/file/delete - * Delete a file - * - * @param \Base $f3 - * @throws \Exception - */ - public function file_delete($f3) { - $file = new \Model\Issue\File; - $file->load($f3->get("POST.id")); - $file->delete(); - $this->_printJson($file->cast()); - } - - /** - * POST /issues/file/undelete - * Un-delete a file - * - * @param \Base $f3 - * @throws \Exception - */ - public function file_undelete($f3) { - $file = new \Model\Issue\File; - $file->load($f3->get("POST.id")); - $file->deleted_date = null; - $file->save(); - $this->_printJson($file->cast()); - } - - /** - * Build an issue search query WHERE clause - * - * @param string $q User query string - * @return array [string, keyword, ...] - */ - protected function _buildSearchWhere($q) { - if(!$q) { - return array("deleted_date IS NULL"); - } - $return = array(); - - // Build WHERE string - $keywordParts = array(); - foreach(explode(" ", $q) as $w) { - $keywordParts[] = "CONCAT(name, description, author_name, owner_name, - author_username, owner_username) LIKE ?"; - $return[] = "%$w%"; - } - if(is_numeric($q)) { - $where = "id = ? OR "; - array_unshift($return, $q); - } else { - $where = ""; - } - $where .= "(" . implode(" AND ", $keywordParts) . ") AND deleted_date IS NULL"; - - // Add WHERE string to return array - array_unshift($return, $where); - return $return; - } - - /** - * GET /search - * Search for issues - * - * @param \Base $f3 - */ - public function search($f3) { - $q = $f3->get("GET.q"); - if(preg_match("/^#([0-9]+)$/", $q, $matches)){ - $f3->reroute("/issues/{$matches[1]}"); - } - - $issues = new \Model\Issue\Detail; - - $args = $f3->get("GET"); - if(empty($args["page"])) { - $args["page"] = 0; - } - - $where = $this->_buildSearchWhere($q); - if(empty($args["closed"])) { - $where[0] .= " AND status_closed = '0'"; - } - - $issue_page = $issues->paginate($args["page"], 50, $where, array("order" => "created_date DESC")); - $f3->set("issues", $issue_page); - - if($issue_page["count"] > 7) { - if($issue_page["pos"] <= 3) { - $min = 0; - } else { - $min = $issue_page["pos"] - 3; - } - if($issue_page["pos"] < $issue_page["count"] - 3) { - $max = $issue_page["pos"] + 3; - } else { - $max = $issue_page["count"] - 1; - } - } else { - $min = 0; - $max = $issue_page["count"] - 1; - } - $f3->set("pages", range($min, $max)); - - $f3->set("show_filters", false); - $this->_render("issues/search.html"); - } - - /** - * POST /issues/upload - * Upload a file - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function upload($f3, $params) { - $user_id = $this->_userId; - - $issue = new \Model\Issue; - $issue->load(array("id=? AND deleted_date IS NULL", $f3->get("POST.issue_id"))); - if(!$issue->id) { - $f3->error(404); - return; - } - - $web = \Web::instance(); - - $f3->set("UPLOADS", "uploads/".date("Y")."/".date("m")."/"); - if(!is_dir($f3->get("UPLOADS"))) { - mkdir($f3->get("UPLOADS"), 0777, true); - } - $overwrite = false; // set to true to overwrite an existing file; Default: false - $slug = true; // rename file to filesystem-friendly version - - // Make a good name - $orig_name = preg_replace("/[^A-Z0-9._-]/i", "_", $_FILES['attachment']['name']); - $_FILES['attachment']['name'] = time() . "_" . $orig_name; - - $i = 0; - $parts = pathinfo($_FILES['attachment']['name']); - while (file_exists($f3->get("UPLOADS") . $_FILES['attachment']['name'])) { - $i++; - $_FILES['attachment']['name'] = $parts["filename"] . "-" . $i . "." . $parts["extension"]; - } - - $web->receive( - function($file) use($f3, $orig_name, $user_id, $issue) { - - if($file['size'] > $f3->get("files.maxsize")) - return false; - - $newfile = new \Model\Issue\File; - $newfile->issue_id = $issue->id; - $newfile->user_id = $user_id; - $newfile->filename = $orig_name; - $newfile->disk_filename = $file['name']; - $newfile->disk_directory = $f3->get("UPLOADS"); - $newfile->filesize = $file['size']; - $newfile->content_type = $file['type']; - $newfile->digest = md5_file($file['tmp_name']); - $newfile->created_date = date("Y-m-d H:i:s"); - $newfile->save(); - $f3->set('file_id', $newfile->id); - - return true; // moves file from php tmp dir to upload dir - }, - $overwrite, - $slug - ); - - if($f3->get("POST.text")) { - $comment = new \Model\Issue\Comment; - $comment->user_id = $this->_userId; - $comment->issue_id = $issue->id; - $comment->text = $f3->get("POST.text"); - $comment->created_date = $this->now(); - $comment->file_id = $f3->get('file_id'); - $comment->save(); - if(!!$f3->get("POST.notify")) { - $notification = \Helper\Notification::instance(); - $notification->issue_comment($issue->id, $comment->id); - } - } elseif($newfile->id && !!$f3->get("POST.notify")) { - $notification = \Helper\Notification::instance(); - $notification->issue_file($issue->id, $f3->get("file_id")); - } - - $f3->reroute("/issues/" . $issue->id); - } - - /** - * GET /issues/project/@id - * Project Overview action - * - * @param \Base $f3 - * @param array $params - */ - public function project_overview($f3, $params) { - - // Load issue - $project = new \Model\Issue\Detail; - $project->load($params["id"]); - if(!$project->id) { - $f3->error(404); - return; - } - if($project->type_id != $f3->get("issue_type.project")) { - $f3->error(400, "Issue is not a project."); - return; - } - - /** - * Helper function to get a percentage of completed issues and some totals across the entire tree - * @param \Model\Issue $issue - * @var callable $completeCount This function, required for recursive calls - * @return array - */ - $projectStats = function(\Model\Issue &$issue) use(&$projectStats) { - $total = 0; - $complete = 0; - $hoursSpent = 0; - $hoursTotal = 0; - if($issue->id) { - $total ++; - if($issue->closed_date) { - $complete ++; - } - if($issue->hours_spent > 0) { - $hoursSpent += $issue->hours_spent; - } - if($issue->hours_total > 0) { - $hoursTotal += $issue->hours_total; - } - foreach($issue->getChildren() as $child) { - $result = $projectStats($child); - $total += $result["total"]; - $complete += $result["complete"]; - $hoursSpent += $result["hours_spent"]; - $hoursTotal += $result["hours_total"]; - } - } - return array( - "total" => $total, - "complete" => $complete, - "hours_spent" => $hoursSpent, - "hours_total" => $hoursTotal, - ); - }; - $f3->set("stats", $projectStats($project)); - - /** - * Helper function for recursive tree rendering - * @param \Model\Issue $issue - * @var callable $renderTree This function, required for recursive calls - */ - $renderTree = function(\Model\Issue &$issue, $level = 0) use(&$renderTree) { - if($issue->id) { - $f3 = \Base::instance(); - $children = $issue->getChildren(); - $hive = array("issue" => $issue, "children" => $children, "dict" => $f3->get("dict"), "BASE" => $f3->get("BASE"), "level" => $level, "issue_type" => $f3->get("issue_type")); - echo \Helper\View::instance()->render("issues/project/tree-item.html", "text/html", $hive); - if($children) { - foreach($children as $item) { - $renderTree($item, $level + 1); - } - } - } - }; - $f3->set("renderTree", $renderTree); - - // Render view - $f3->set("project", $project); - $f3->set("title", $project->type_name . " #" . $project->id . ": " . $project->name . " - " . $f3->get("dict.project_overview")); - $this->_render("issues/project.html"); - } - - /** - * GET /issues/parent_ajax - * Load all matching issues - * - * @param \Base $f3 - */ - public function parent_ajax($f3) { - if(!$f3->get("AJAX")) { - $f3->error(400); - } - - $term = trim($f3->get('GET.q')); - $results = array(); - - $issue = new \Model\Issue; - if((substr($term, 0, 1) == '#') && is_numeric(substr($term, 1))) { - $id = (int) substr($term, 1); - $issues = $issue->find(array('id LIKE ?', $id. '%'), array('limit' => 20)); - - foreach($issues as $row) { - $results[] = array('id'=>$row->get('id'), 'text'=>$row->get('name')); - } - } - elseif(is_numeric($term)) { - $id = (int) $term; - $issues = $issue->find(array('(id LIKE ?) OR (name LIKE ?)', $id . '%', '%' . $id . '%'), array('limit' => 20)); - - foreach($issues as $row) { - $results[] = array('id'=>$row->get('id'), 'text'=>$row->get('name')); - } - } - else { - $issues = $issue->find(array('name LIKE ?', '%' . addslashes($term) . '%'), array('limit' => 20)); - - foreach($issues as $row) { - $results[] = array('id'=>$row->get('id'), 'text'=>$row->get('name')); - } - } - - $this->_printJson(array('results' => $results)); - } - +class Issues extends \Controller +{ + protected $_userId; + + /** + * Require login on new + */ + public function __construct() + { + $this->_userId = $this->_requireLogin(); + } + + /** + * Clean a string for encoding in JSON + * Collapses whitespace, then trims + * + * @param string $string + * @return string + */ + protected function _cleanJson($string) + { + return trim(preg_replace('/\s+/', ' ', $string)); + } + + /** + * Build a WHERE clause for issue listings based on the current filters and sort options + * + * @return array + */ + protected function _buildFilter() + { + $f3 = \Base::instance(); + $db = $f3->get("db.instance"); + $issues = new \Model\Issue\Detail(); + + // Filter issue listing by URL parameters + $filter = []; + $args = $f3->get("GET"); + foreach ($args as $key => $val) { + if ($issues->exists($key)) { + if ($val == '-1') { + $filter[$key] = null; + } elseif (!empty($val) && !is_array($val)) { + $filter[$key] = $val; + } + } + } + unset($val); + + // Build SQL string to use for filtering + $filter_str = ""; + foreach ($filter as $field => $val) { + if ($field == "name") { + $filter_str .= "`$field` LIKE " . $db->quote("%$val%") . " AND "; + } elseif ($field == "status" && $val == "open") { + $filter_str .= "status_closed = 0 AND "; + } elseif ($field == "status" && $val == "closed") { + $filter_str .= "status_closed = 1 AND "; + } elseif ($field == "repeat_cycle" && $val == "repeat") { + $filter_str .= "repeat_cycle IS NOT NULL AND "; + } elseif ($field == "repeat_cycle" && $val == "none") { + $filter_str .= "repeat_cycle IS NULL AND "; + } elseif (($field == "author_id" || $field == "owner_id") && !empty($val) && is_numeric($val)) { + // Find all users in a group if necessary + $user = new \Model\User(); + $user->load($val); + if ($user->role == 'group') { + $groupUsers = new \Model\User\Group(); + $list = $groupUsers->find(['group_id = ?', $val]); + $groupUserArray = [$val]; // Include the group in the search + foreach ($list as $obj) { + $groupUserArray[] = $obj->user_id; + } + $filter_str .= "$field in (" . implode(",", $groupUserArray) . ") AND "; + } else { + // Just select by user + $filter_str .= "$field = " . $db->quote($val) . " AND "; + } + } elseif ($val === null) { + $filter_str .= "`$field` IS NULL AND "; + } else { + $filter_str .= "`$field` = " . $db->quote($val) . " AND "; + } + } + unset($val); + $filter_str .= " deleted_date IS NULL "; + + // Build SQL ORDER BY string + $orderby = !empty($args['orderby']) ? $args['orderby'] : "priority"; + $filter["orderby"] = $orderby; + $ascdesc = !empty($args['ascdesc']) && strtolower($args['ascdesc']) == 'asc' ? "ASC" : "DESC"; + $filter["ascdesc"] = $ascdesc; + switch ($orderby) { + case "id": + $filter_str .= " ORDER BY id {$ascdesc} "; + break; + case "title": + $filter_str .= " ORDER BY name {$ascdesc}"; + break; + case "type": + $filter_str .= " ORDER BY type_id {$ascdesc}, priority DESC, due_date DESC "; + break; + case "status": + $filter_str .= " ORDER BY status {$ascdesc}, priority DESC, due_date DESC "; + break; + case "parent_id": + $filter_str .= " ORDER BY parent_id {$ascdesc}, priority DESC, due_date DESC "; + break; + case "author": + $filter_str .= " ORDER BY author_name {$ascdesc}, priority DESC, due_date DESC "; + break; + case "assignee": + $filter_str .= " ORDER BY owner_name {$ascdesc}, priority DESC, due_date DESC "; + break; + case "created": + $filter_str .= " ORDER BY created_date {$ascdesc}, priority DESC, due_date DESC "; + break; + case "due": + $filter_str .= " ORDER BY due_date {$ascdesc}, priority DESC"; + break; + case "sprint": + $filter_str .= " ORDER BY sprint_start_date {$ascdesc}, priority DESC, due_date DESC "; + break; + case "closed": + $filter_str .= " ORDER BY closed_date {$ascdesc}, priority DESC, due_date DESC "; + break; + case "priority": + default: + $filter_str .= " ORDER BY priority {$ascdesc}, due_date DESC "; + break; + } + + return [$filter, $filter_str]; + } + + /** + * GET /issues + * Display a sortable, filterable issue list + * + * @param \Base $f3 + */ + public function index($f3) + { + $issues = new \Model\Issue\Detail(); + + // Get filter + $args = $f3->get("GET"); + + // load all issues if user is admin, otherwise load by group access + $user = $f3->get("user_obj"); + if ($user->role == 'admin' || !$f3->get('security.restrict_access')) { + [$filter, $filter_str] = $this->_buildFilter(); + } else { + $helper = \Helper\Dashboard::instance(); + $groupString = implode(",", array_merge($helper->getGroupIds(), [$user->id])); + + // Get filter + [$filter, $filter_str] = $this->_buildFilter(); + $filter_str = "(owner_id IN (" . $groupString . ")) AND " . $filter_str; + } + + // Load type if a type_id was passed + $type = new \Model\Issue\Type(); + if (!empty($args["type_id"])) { + $type->load($args["type_id"]); + if ($type->id) { + $f3->set("title", $f3->get("dict.issues") . " - " . $f3->get("dict.by_type") . ": " . $type->name); + $f3->set("type", $type); + } + } else { + $f3->set("title", $f3->get("dict.issues")); + } + + $status = new \Model\Issue\Status(); + $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); + + $priority = new \Model\Issue\Priority(); + $f3->set("priorities", $priority->find(null, ["order" => "value DESC"], $f3->get("cache_expire.db"))); + + $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); + + $sprint = new \Model\Sprint(); + $f3->set("sprints", $sprint->find(["end_date >= ?", date("Y-m-d")], ["order" => "start_date ASC, id ASC"])); + $f3->set("old_sprints", $sprint->find(["end_date < ?", date("Y-m-d")], ["order" => "start_date ASC, id ASC"])); + + $users = new \Model\User(); + $f3->set("users", $users->getAll()); + $f3->set("deleted_users", $users->getAllDeleted()); + $f3->set("groups", $users->getAllGroups()); + + if (empty($args["page"])) { + $args["page"] = 0; + } + $issue_page = $issues->paginate($args["page"], 50, $filter_str); + $f3->set("issues", $issue_page); + + // Pass filter string for pagination + $filter_get = http_build_query($filter); + + if (!empty($orderby)) { + $filter_get .= "&orderby=" . $orderby; + } + if ($issue_page["count"] > 7) { + if ($issue_page["pos"] <= 3) { + $min = 0; + } else { + $min = $issue_page["pos"] - 3; + } + if ($issue_page["pos"] < $issue_page["count"] - 3) { + $max = $issue_page["pos"] + 3; + } else { + $max = $issue_page["count"] - 1; + } + } else { + $min = 0; + $max = $issue_page["count"] - 1; + } + $f3->set("pages", range($min, $max)); + $f3->set("filter_get", $filter_get); + + $f3->set("menuitem", "browse"); + $f3->set("heading_links_enabled", true); + + $f3->set("show_filters", true); + $f3->set("show_export", true); + + $this->_render("issues/index.html"); + } + + /** + * POST /issues/bulk_update + * Update a list of issues + * + * @param \Base $f3 + */ + public function bulk_update($f3) + { + $this->validateCsrf(); + $post = $f3->get("POST"); + + $issue = new \Model\Issue(); + if (!empty($post["id"]) && is_array($post["id"])) { + foreach ($post["id"] as $id) { + // Updating existing issue. + $issue->load($id); + if ($issue->id) { + if (!empty($post["update_copy"])) { + $issue = $issue->duplicate(false); + } + + // Diff contents and save what's changed. + foreach ($post as $i => $val) { + if ( + $issue->exists($i) + && $i != "id" + && $issue->$i != $val + && (!empty($val) || $val === "0") + ) { + // Allow setting to Not Assigned + if (($i == "owner_id" || $i == "sprint_id") && $val == -1) { + $val = null; + } + $issue->$i = $val; + if ($i == "status") { + $status = new \Model\Issue\Status(); + $status->load($val); + + // Toggle closed_date if issue has been closed/restored + if ($status->closed) { + if (!$issue->closed_date) { + $issue->closed_date = $this->now(); + } + } else { + $issue->closed_date = null; + } + } + } + } + + // Save to the sprint of the due date if no sprint selected + if (!empty($post['due_date']) && empty($post['sprint_id']) && !empty($post['due_date_sprint'])) { + $sprint = new \Model\Sprint(); + $sprint->load(["DATE(?) BETWEEN start_date AND end_date", $issue->due_date]); + $issue->sprint_id = $sprint->id; + } + + // If it's a child issue and the parent is in a sprint, assign to that sprint + if (!empty($post['bulk']['parent_id']) && !$issue->sprint_id) { + $parent = new \Model\Issue(); + $parent->load($issue->parent_id); + if ($parent->sprint_id) { + $issue->sprint_id = $parent->sprint_id; + } + } + + $notify = !empty($post["notify"]); + $issue->save($notify); + } else { + $f3->error(500, "Failed to update all the issues, starting with: $id."); + return; + } + } + } + + $f3->reroute("/issues?" . $post["url_query"]); + } + + /** + * GET /issues/export + * Export a list of issues + * + * @param \Base $f3 + */ + public function export($f3) + { + $issue = new \Model\Issue\Detail(); + + // Get filter data and load issues + $filter = $this->_buildFilter(); + $issues = $issue->find($filter[1]); + + // Configure visible fields + $fields = [ + "id" => $f3->get("dict.cols.id"), + "name" => $f3->get("dict.cols.title"), + "type_name" => $f3->get("dict.cols.type"), + "priority_name" => $f3->get("dict.cols.priority"), + "status_name" => $f3->get("dict.cols.status"), + "author_name" => $f3->get("dict.cols.author"), + "owner_name" => $f3->get("dict.cols.assignee"), + "sprint_name" => $f3->get("dict.cols.sprint"), + "created_date" => $f3->get("dict.cols.created"), + "due_date" => $f3->get("dict.cols.due_date"), + "closed_date" => $f3->get("dict.cols.closed_date"), + ]; + + // Notify browser that file is a CSV, send as attachment (force download) + header("Content-type: text/csv"); + header("Content-Disposition: attachment; filename=issues-" . time() . ".csv"); + header("Pragma: no-cache"); + header("Expires: 0"); + + // Output data directly + $fh = fopen("php://output", "w"); + + // Add column headings + fwrite($fh, '"' . implode('","', array_values($fields)) . "\"\n"); + + // Add rows + foreach ($issues as $row) { + $cols = []; + foreach (array_keys($fields) as $field) { + $cols[] = $row->get($field); + } + fputcsv($fh, $cols); + } + + fclose($fh); + } + + /** + * GET /issues/new/@type + * GET /issues/new/@type/@parent + * Create a new issue + * + * @param \Base $f3 + */ + public function add($f3) + { + if ($f3->get("PARAMS.type")) { + $type_id = $f3->get("PARAMS.type"); + } else { + $type_id = 1; + } + + $type = new \Model\Issue\Type(); + $type->load($type_id); + + if (!$type->id) { + $f3->error(404, "Issue type does not exist"); + return; + } + + if ($f3->get("PARAMS.parent")) { + $parent_id = $f3->get("PARAMS.parent"); + $parent = new \Model\Issue(); + $parent->load(["id = ?", $parent_id]); + if ($parent->id) { + $f3->set("parent", $parent); + } + } + + $f3->set('owner_id', null); + if ($f3->get("GET.owner_id")) { + $f3->set("owner_id", $f3->get("GET.owner_id")); + } + + $status = new \Model\Issue\Status(); + $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); + + $priority = new \Model\Issue\Priority(); + $f3->set("priorities", $priority->find(null, ["order" => "value DESC"], $f3->get("cache_expire.db"))); + + $sprint = new \Model\Sprint(); + $f3->set("sprints", $sprint->find(["end_date >= ?", $this->now(false)], ["order" => "start_date ASC, id ASC"])); + + $users = new \Model\User(); + + $helper = \Helper\Dashboard::instance(); + + // Load all issues if user is admin, otherwise load by group access + $groupUserFilter = ""; + $groupFilter = ""; + + $user = $f3->get("user_obj"); + if ($user->role != 'admin' && $f3->get('security.restrict_access')) { + // TODO: restrict user/group list when user is not in any groups + if ($helper->getGroupUserIds()) { + $groupUserFilter = " AND id IN (" . implode(",", array_merge($helper->getGroupUserIds(), [$user->id])) . ")"; + } + if ($helper->getGroupIds()) { + $groupFilter = " AND id IN (" . implode(",", $helper->getGroupIds()) . ")"; + } + } + + $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'" . $groupUserFilter, ["order" => "name ASC"])); + $f3->set("groups", $users->find("deleted_date IS NULL AND role = 'group'" . $groupFilter, ["order" => "name ASC"])); + $f3->set("title", $f3->get("dict.new_n", $type->name)); + $f3->set("menuitem", "new"); + $f3->set("type", $type); + + $this->_render("issues/edit.html"); + } + + /** + * @param \Base $f3 + */ + public function add_selecttype($f3) + { + $type = new \Model\Issue\Type(); + $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); + + $f3->set("title", $f3->get("dict.new_n", $f3->get("dict.issues"))); + $f3->set("menuitem", "new"); + $this->_render("issues/new.html"); + } + + /** + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function edit($f3, $params) + { + $issue = new \Model\Issue\Detail(); + $issue->load($params["id"]); + + if (!$issue->id) { + $f3->error(404, "Issue does not exist"); + return; + } + + if (!$issue->allowAccess()) { + $f3->error(403); + return; + } + + $type = new \Model\Issue\Type(); + $type->load($issue->type_id); + + $this->loadIssueMeta($issue); + + $f3->set("title", $f3->get("edit_n", $issue->id)); + $f3->set("issue", $issue); + $f3->set("type", $type); + + if ($f3->get("AJAX")) { + $this->_render("issues/edit-form.html"); + } else { + $this->_render("issues/edit.html"); + } + } + + /** + * Load metadata lists for displaying issue edit forms + * @param \Model\Issue $issue + * @return void + */ + protected function loadIssueMeta(\Model\Issue $issue) + { + $f3 = \Base::instance(); + $status = new \Model\Issue\Status(); + $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); + + $priority = new \Model\Issue\Priority(); + $f3->set("priorities", $priority->find(null, ["order" => "value DESC"], $f3->get("cache_expire.db"))); + + $sprint = new \Model\Sprint(); + $sprintOrder = ["order" => "start_date ASC, id ASC"]; + $f3->set("sprints", $sprint->find(["end_date >= ?", $this->now(false)], $sprintOrder)); + $f3->set("old_sprints", $sprint->find(["end_date < ?", $this->now(false)], $sprintOrder)); + + $users = new \Model\User(); + $helper = \Helper\Dashboard::instance(); + + // load all issues if user is admin, otherwise load by group access + $groupUserFilter = ""; + $groupFilter = ""; + + $user = $f3->get("user_obj"); + if ($user->role != 'admin' && $f3->get('security.restrict_access')) { + // TODO: restrict user/group list when user is not in any groups + if ($helper->getGroupUserIds()) { + $groupUserFilter = " AND id IN (" . implode(",", array_merge($helper->getGroupUserIds(), [$user->id])) . ")"; + } + if ($helper->getGroupIds()) { + $groupFilter = " AND id IN (" . implode(",", $helper->getGroupIds()) . ")"; + } + } + + $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'" . $groupUserFilter, ["order" => "name ASC"])); + $f3->set("groups", $users->find("deleted_date IS NULL AND role = 'group'" . $groupFilter, ["order" => "name ASC"])); + } + + /** + * POST /issues/close/@id + * Close an issue + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function close($f3, $params) + { + $this->validateCsrf(); + $issue = new \Model\Issue(); + $issue->load($params["id"]); + + if (!$issue->id) { + $f3->error(404, "Issue does not exist"); + return; + } + + if (!$issue->allowAccess()) { + $f3->error(403); + return; + } + + $issue->close(); + + $f3->reroute("/issues/" . $issue->id); + } + + /** + * POST /issues/reopen/@id + * Reopen an issue + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function reopen($f3, $params) + { + $this->validateCsrf(); + $issue = new \Model\Issue(); + $issue->load($params["id"]); + + if (!$issue->id) { + $f3->error(404, "Issue does not exist"); + return; + } + + if (!$issue->allowAccess()) { + $f3->error(403); + return; + } + + if ($issue->closed_date) { + $status = new \Model\Issue\Status(); + $status->load(["closed = ?", 0]); + $issue->status = $status->id; + $issue->closed_date = null; + $issue->save(); + } + + $f3->reroute("/issues/" . $issue->id); + } + + /** + * POST /issues/copy/@id + * Copy an issue + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function copy($f3, $params) + { + $this->validateCsrf(); + $issue = new \Model\Issue(); + $issue->load($params["id"]); + + if (!$issue->id) { + $f3->error(404, "Issue does not exist"); + return; + } + + if (!$issue->allowAccess()) { + $f3->error(403); + return; + } + + $new_issue = $issue->duplicate(); + + if ($new_issue->id) { + $f3->reroute("/issues/" . $new_issue->id); + } else { + $f3->error(500, "Failed to duplicate issue."); + } + } + + /** + * Save an updated issue + * + * @return \Model\Issue + */ + protected function _saveUpdate() + { + $f3 = \Base::instance(); + + // Remove parent if user has no rights to it + if ($f3->get("POST.parent_id")) { + $parentIssue = (new \Model\Issue())->load(intval($f3->get("POST.parent_id"))); + if (!$parentIssue->allowAccess()) { + $f3->set("POST.parent_id", null); + } + } + + $post = array_map("trim", $f3->get("POST")); + $issue = new \Model\Issue(); + + // Load issue and return if not set + $issue->load($post["id"]); + if (!$issue->id) { + return $issue; + } + + $newSprint = false; + + // Diff contents and save what's changed. + $hashState = json_decode($post["hash_state"], null, 512, JSON_THROW_ON_ERROR); + foreach ($post as $i => $val) { + if ( + $issue->exists($i) + && $i != "id" + && $issue->$i != $val + && md5($val) != $hashState->$i + ) { + if ($i == 'sprint_id') { + $newSprint = empty($val) ? null : $val; + } + if (empty($val)) { + $issue->$i = null; + } else { + $issue->$i = $val; + + if ($i == "status") { + $status = new \Model\Issue\Status(); + $status->load($val); + + // Toggle closed_date if issue has been closed/restored + if ($status->closed) { + if (!$issue->closed_date) { + $issue->closed_date = $this->now(); + } + } else { + $issue->closed_date = null; + } + } + + // Save to the sprint of the due date unless one already set + if ($i == "due_date" && !empty($val)) { + if (empty($post['sprint_id']) && !empty($post['due_date_sprint'])) { + $sprint = new \Model\Sprint(); + $sprint->load(["DATE(?) BETWEEN start_date AND end_date", $val]); + $issue->sprint_id = $sprint->id; + $newSprint = $sprint->id; + } + } + } + } + } + + // Update child issues' sprint if sprint was changed + if ($newSprint !== false) { + $children = $issue->find([ + 'parent_id = ? AND type_id IN (SELECT id FROM issue_type WHERE role = "task")', + $issue->id, + ]); + foreach ($children as $child) { + $child->sprint_id = $newSprint; + $child->save(false); + } + } + + // Save comment if given + if (!empty($post["comment"])) { + $comment = new \Model\Issue\Comment(); + $comment->user_id = $this->_userId; + $comment->issue_id = $issue->id; + $comment->text = $post["comment"]; + $comment->created_date = $this->now(); + $comment->save(); + $f3->set("update_comment", $comment); + } + + // Save issue, optionally send notifications + $notify = !empty($post["notify"]); + $issue->save($notify); + + return $issue; + } + + /** + * Create a new issue from $_POST + * + * @return \Model\Issue + */ + protected function _saveNew() + { + $f3 = \Base::instance(); + $data = $f3->get("POST"); + $originalAuthor = null; + if (!empty($data['author_id']) && $data['author_id'] != $this->_userId) { + $originalAuthor = $data['author_id']; + $data['author_id'] = $this->_userId; + } + + // Remove parent if user has no rights to it + if (!empty($data['parent_id'])) { + $parentIssue = (new \Model\Issue())->load(intval($data['parent_id'])); + if (!$parentIssue->allowAccess()) { + $data['parent_id'] = null; + } + } + + $issue = \Model\Issue::create($data, !!$f3->get("POST.notify")); + if ($originalAuthor) { + $issue->author_id = $originalAuthor; + $issue->save(false); + } + return $issue; + } + + /** + * POST /issues + * Save an issue + * + * @param \Base $f3 + */ + public function save($f3) + { + $this->validateCsrf(); + if ($f3->get("POST.id")) { + // Updating existing issue. + $issue = $this->_saveUpdate(); + if ($issue->id) { + $f3->reroute("/issues/" . $issue->id); + } else { + $f3->error(404, "This issue does not exist."); + } + } elseif ($f3->get("POST.name")) { + // Creating new issue. + $issue = $this->_saveNew(); + if ($issue->id) { + $f3->reroute("/issues/" . $issue->id); + } else { + $f3->error(500, "An error occurred saving the issue."); + } + } else { + $f3->reroute("/issues/new/" . $f3->get("POST.type_id")); + } + } + + /** + * GET /issues/@id + * View an issue + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single($f3, $params) + { + $issue = new \Model\Issue\Detail(); + $issue->load(["id=?", $params["id"]]); + + // load issue if user is admin, otherwise load by group access + if (!$issue->id || !$issue->allowAccess()) { + $f3->error(404); + return; + } + + $type = new \Model\Issue\Type(); + $type->load($issue->type_id); + + $f3->set("title", $type->name . " #" . $issue->id . ": " . $issue->name); + $f3->set("menuitem", "browse"); + + $author = new \Model\User(); + $author->load($issue->author_id); + $owner = new \Model\User(); + if ($issue->owner_id) { + $owner->load($issue->owner_id); + } + + $files = new \Model\Issue\File\Detail(); + $f3->set("files", $files->find(["issue_id = ? AND deleted_date IS NULL", $issue->id])); + + if ($issue->sprint_id) { + $sprint = new \Model\Sprint(); + $sprint->load($issue->sprint_id); + $f3->set("sprint", $sprint); + } + + $watching = new \Model\Issue\Watcher(); + $watching->load(["issue_id = ? AND user_id = ?", $issue->id, $this->_userId]); + $f3->set("watching", !!$watching->id); + + $f3->set("issue", $issue); + $f3->set("ancestors", $issue->getAncestors()); + $f3->set("type", $type); + $f3->set("author", $author); + $f3->set("owner", $owner); + + $comments = new \Model\Issue\Comment\Detail(); + $f3->set("comments", $comments->find(["issue_id = ?", $issue->id], ["order" => "created_date DESC, id DESC"])); + + $this->loadIssueMeta($issue); + + $this->_render("issues/single.html"); + } + + /** + * POST /issues/@id/watchers + * Add a watcher + * + * @param \Base $f3 + * @param array $params + */ + public function add_watcher($f3, $params) + { + $this->validateCsrf(); + + $issue = new \Model\Issue(); + $issue->load(["id=?", $params["id"]]); + if (!$issue->id) { + $f3->error(404); + } + + $watcher = new \Model\Issue\Watcher(); + + // Loads just in case the user is already a watcher + $watcher->load(["issue_id = ? AND user_id = ?", $issue->id, $f3->get("POST.user_id")]); + if (!$watcher->id) { + $watcher->issue_id = $issue->id; + $watcher->user_id = $f3->get("POST.user_id"); + $watcher->save(); + } + } + + /** + * POST /issues/@id/watchers/delete + * Delete a watcher + * + * @param \Base $f3 + * @param array $params + */ + public function delete_watcher($f3, $params) + { + $this->validateCsrf(); + + $issue = new \Model\Issue(); + $issue->load(["id=?", $params["id"]]); + if (!$issue->id) { + $f3->error(404); + } + + $watcher = new \Model\Issue\Watcher(); + + $watcher->load(["issue_id = ? AND user_id = ?", $issue->id, $f3->get("POST.user_id")]); + $watcher->delete(); + } + + /** + * POST /issues/@id/dependencies + * Add a dependency + * + * @param \Base $f3 + * @param array $params + */ + public function add_dependency($f3, $params) + { + $this->validateCsrf(); + + $issue = new \Model\Issue(); + $issue->load(["id=?", $params["id"]]); + if (!$issue->id) { + $f3->error(404); + } + + $dependency = new \Model\Issue\Dependency(); + + // Loads just in case the task is already a dependency + $dependency->load(["issue_id = ? AND dependency_id = ?", $issue->id, $f3->get("POST.id")]); + $dependency->issue_id = $f3->get("POST.issue_id"); + $dependency->dependency_id = $f3->get("POST.dependency_id"); + $dependency->dependency_type = $f3->get("POST.type"); + $dependency->save(); + } + + /** + * POST /issues/@id/dependencies/delete + * Delete a dependency + * + * @param \Base $f3 + * @param array $params + */ + public function delete_dependency($f3, $params) + { + $this->validateCsrf(); + + $issue = new \Model\Issue(); + $issue->load(["id=?", $params["id"]]); + if (!$issue->id) { + $f3->error(404); + } + + $dependency = new \Model\Issue\Dependency(); + $dependency->load($f3->get("POST.id")); + $dependency->delete(); + } + + /** + * GET /issues/@id/history + * AJAX call for issue history + * + * @param \Base $f3 + * @param array $params + */ + public function single_history($f3, $params) + { + // Build updates array + $updates_array = []; + $update_model = new \Model\Custom("issue_update_detail"); + $updates = $update_model->find(["issue_id = ?", $params["id"]], ["order" => "created_date DESC, id DESC"]); + foreach ($updates as $update) { + $update_array = $update->cast(); + $update_field_model = new \Model\Issue\Update\Field(); + $update_array["changes"] = $update_field_model->find(["issue_update_id = ?", $update["id"]]); + $updates_array[] = $update_array; + } + + $f3->set("updates", $updates_array); + + $this->_printJson([ + "total" => count($updates), + "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/history.html")), + ]); + } + + /** + * GET /issues/@id/related + * AJAX call for related issues + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single_related($f3, $params) + { + $issue = new \Model\Issue(); + $issue->load($params["id"]); + + if (!$issue->id) { + return $f3->error(404); + } + + if (!$issue->allowAccess()) { + return $f3->error(403); + } + + $f3->set("parent", $issue); + + $issues = new \Model\Issue\Detail(); + if ($exclude = $f3->get("GET.exclude")) { + $searchParams = ["parent_id = ? AND id != ? AND deleted_date IS NULL", $issue->id, $exclude]; + } else { + $searchParams = ["parent_id = ? AND deleted_date IS NULL", $issue->id]; + } + $orderParams = ["order" => "status_closed, priority DESC, due_date"]; + $f3->set("issues", $issues->find($searchParams, $orderParams)); + + $searchParams[0] = $searchParams[0] . " AND status_closed = 0"; + $openIssues = $issues->count($searchParams); + + $this->_printJson([ + "total" => count($f3->get("issues")), + "open" => $openIssues, + "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/related.html")), + ]); + } + + /** + * GET /issues/@id/watchers + * AJAX call for issue watchers + * + * @param \Base $f3 + * @param array $params + */ + public function single_watchers($f3, $params) + { + $watchers = new \Model\Custom("issue_watcher_user"); + $f3->set("watchers", $watchers->find(["issue_id = ?", $params["id"]])); + $users = new \Model\User(); + $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", ["order" => "name ASC"])); + + $this->_printJson([ + "total" => count($f3->get("watchers")), + "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/watchers.html")), + ]); + } + + /** + * GET /issues/@id/dependencies + * AJAX call for issue dependencies + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single_dependencies($f3, $params) + { + $issue = new \Model\Issue(); + $issue->load($params["id"]); + + if ($issue->id) { + $f3->set("issue", $issue); + $dependencies = new \Model\Issue\Dependency(); + $f3->set("dependencies", $dependencies->findby_issue($issue->id)); + $f3->set("dependents", $dependencies->findby_dependent($issue->id)); + + $this->_printJson([ + "total" => count($f3->get("dependencies")) + count($f3->get("dependents")), + "html" => $this->_cleanJson(\Helper\View::instance()->render("issues/single/dependencies.html")), + ]); + } else { + $f3->error(404); + } + } + + /** + * POST /issues/delete/@id + * Delete an issue + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single_delete($f3, $params) + { + $this->validateCsrf(); + $issue = new \Model\Issue(); + $issue->load($params["id"]); + $user = $f3->get("user_obj"); + if ($user->role == "admin" || $user->rank >= \Model\User::RANK_MANAGER || $issue->author_id == $user->id) { + $issue->delete(); + $f3->reroute("/issues/{$issue->id}"); + } else { + $f3->error(403); + } + } + + /** + * POST /issues/undelete/@id + * Un-delete an issue + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single_undelete($f3, $params) + { + $this->validateCsrf(); + $issue = new \Model\Issue(); + $issue->load($params["id"]); + $user = $f3->get("user_obj"); + if ($user->role == "admin" || $user->rank >= \Model\User::RANK_MANAGER || $issue->author_id == $user->id) { + $issue->restore(); + $f3->reroute("/issues/{$issue->id}"); + } else { + $f3->error(403); + } + } + + /** + * POST /issues/comment/save + * Save a comment + * + * @param \Base $f3 + * @throws \Exception + */ + public function comment_save($f3) + { + $this->validateCsrf(); + $post = $f3->get("POST"); + + $issue = new \Model\Issue(); + $issue->load($post["issue_id"]); + + if (!$issue->id || empty($post["text"])) { + if ($f3->get("AJAX")) { + $this->_printJson(["error" => 1]); + } else { + $f3->reroute("/issues/" . $post["issue_id"]); + } + return; + } + + if ($f3->get("POST.action") == "close") { + $issue->close(); + } + + $comment = \Model\Issue\Comment::create(["issue_id" => $post["issue_id"], "user_id" => $this->_userId, "text" => trim($post["text"])], !!$f3->get("POST.notify")); + + if ($f3->get("AJAX")) { + $this->_printJson([ + "id" => $comment->id, + "text" => \Helper\View::instance()->parseText($comment->text, ["hashtags" => false]), + "date_formatted" => date("D, M j, Y \\a\\t g:ia", \Helper\View::instance()->utc2local(time())), + "user_name" => $f3->get('user.name'), + "user_username" => $f3->get('user.username'), + "user_email" => $f3->get('user.email'), + "user_email_md5" => md5(strtolower($f3->get('user.email'))), + ]); + return; + } else { + $f3->reroute("/issues/" . $comment->issue_id); + } + } + + /** + * POST /issues/comment/delete + * Delete a comment + * + * @param \Base $f3 + * @throws \Exception + */ + public function comment_delete($f3) + { + $this->validateCsrf(); + $this->_requireAdmin(); + $comment = new \Model\Issue\Comment(); + $comment->load($f3->get("POST.id")); + $comment->delete(); + $this->_printJson(["id" => $f3->get("POST.id")] + $comment->cast()); + } + + /** + * POST /issues/file/delete + * Delete a file + * + * @param \Base $f3 + * @throws \Exception + */ + public function file_delete($f3) + { + $this->validateCsrf(); + $file = new \Model\Issue\File(); + $file->load($f3->get("POST.id")); + $file->delete(); + $this->_printJson($file->cast()); + } + + /** + * POST /issues/file/undelete + * Un-delete a file + * + * @param \Base $f3 + * @throws \Exception + */ + public function file_undelete($f3) + { + $this->validateCsrf(); + $file = new \Model\Issue\File(); + $file->load($f3->get("POST.id")); + $file->deleted_date = null; + $file->save(); + $this->_printJson($file->cast()); + } + + /** + * Build an issue search query WHERE clause + * + * @param string $q User query string + * @return array [string, keyword, ...] + */ + protected function _buildSearchWhere($q) + { + if (!$q) { + return ["deleted_date IS NULL"]; + } + $return = []; + + // Build WHERE string + $keywordParts = []; + foreach (explode(" ", $q) as $w) { + $keywordParts[] = "CONCAT(name, description, author_name, owner_name, + author_username, owner_username) LIKE ?"; + $return[] = "%$w%"; + } + if (is_numeric($q)) { + $where = "id = ? OR "; + array_unshift($return, $q); + } else { + $where = ""; + } + $where .= "(" . implode(" AND ", $keywordParts) . ") AND deleted_date IS NULL"; + + // Add WHERE string to return array + array_unshift($return, $where); + return $return; + } + + /** + * GET /search + * Search for issues + * + * @param \Base $f3 + */ + public function search($f3) + { + $q = $f3->get("GET.q"); + if (preg_match("/^#([0-9]+)$/", $q, $matches)) { + $f3->reroute("/issues/{$matches[1]}"); + } + + $issues = new \Model\Issue\Detail(); + + $args = $f3->get("GET"); + if (empty($args["page"])) { + $args["page"] = 0; + } + + $where = $this->_buildSearchWhere($q); + if (empty($args["closed"])) { + $where[0] .= " AND status_closed = '0'"; + } + + // load search for all issues if user is admin, otherwise load by group access + $user = $f3->get("user_obj"); + if ($user->role != 'admin') { + $helper = \Helper\Dashboard::instance(); + $groupString = implode(",", array_merge($helper->getGroupIds(), [$user->id])); + $where[0] .= " AND (owner_id IN (" . $groupString . "))"; + } + + $issue_page = $issues->paginate($args["page"], 50, $where, ["order" => "created_date DESC, id DESC"]); + $f3->set("issues", $issue_page); + + if ($issue_page["count"] > 7) { + if ($issue_page["pos"] <= 3) { + $min = 0; + } else { + $min = $issue_page["pos"] - 3; + } + if ($issue_page["pos"] < $issue_page["count"] - 3) { + $max = $issue_page["pos"] + 3; + } else { + $max = $issue_page["count"] - 1; + } + } else { + $min = 0; + $max = $issue_page["count"] - 1; + } + $f3->set("pages", range($min, $max)); + + $f3->set("show_filters", false); + $this->_render("issues/search.html"); + } + + /** + * POST /issues/upload + * Upload a file + * + * @param \Base $f3 + * @throws \Exception + */ + public function upload($f3) + { + $this->validateCsrf(); + $user_id = $this->_userId; + + $issue = new \Model\Issue(); + $issue->load(["id=? AND deleted_date IS NULL", $f3->get("POST.issue_id")]); + if (!$issue->id) { + $f3->error(404); + return; + } + + $web = \Web::instance(); + + $f3->set("UPLOADS", "uploads/" . date("Y") . "/" . date("m") . "/"); + if (!is_dir($f3->get("UPLOADS"))) { + mkdir($f3->get("UPLOADS"), 0777, true); + } + $overwrite = false; // set to true to overwrite an existing file; Default: false + $slug = true; // rename file to filesystem-friendly version + + // Make a good name + $orig_name = preg_replace("/[^A-Z0-9._-]/i", "_", $_FILES['attachment']['name']); + $_FILES['attachment']['name'] = time() . "_" . $orig_name; + + // Blacklist certain file types + if ($f3->get('security.file_blacklist')) { + if (preg_match($f3->get('security.file_blacklist'), $orig_name)) { + $f3->error(415); + return; + } + } + + $i = 0; + $parts = pathinfo($_FILES['attachment']['name']); + while (file_exists($f3->get("UPLOADS") . $_FILES['attachment']['name'])) { + $i++; + $_FILES['attachment']['name'] = $parts["filename"] . "-" . $i . "." . $parts["extension"]; + } + + $web->receive( + function ($file) use ($f3, $orig_name, $user_id, $issue) { + if ($file['size'] > $f3->get("files.maxsize")) { + return false; + } + + $newfile = new \Model\Issue\File(); + $newfile->issue_id = $issue->id; + $newfile->user_id = $user_id; + $newfile->filename = $orig_name; + $newfile->disk_filename = $file['name']; + $newfile->disk_directory = $f3->get("UPLOADS"); + $newfile->filesize = $file['size']; + $newfile->content_type = $file['type']; + $newfile->digest = md5_file($file['tmp_name']); + $newfile->created_date = date("Y-m-d H:i:s"); + $newfile->save(); + $f3->set('file_id', $newfile->id); + + return true; // moves file from php tmp dir to upload dir + }, + $overwrite, + $slug + ); + + if ($f3->get("POST.text")) { + $comment = new \Model\Issue\Comment(); + $comment->user_id = $this->_userId; + $comment->issue_id = $issue->id; + $comment->text = $f3->get("POST.text"); + $comment->created_date = $this->now(); + $comment->file_id = $f3->get('file_id'); + $comment->save(); + if (!!$f3->get("POST.notify")) { + $notification = \Helper\Notification::instance(); + $notification->issue_comment($issue->id, $comment->id); + } + } elseif ($f3->get('file_id') && !!$f3->get("POST.notify")) { + $notification = \Helper\Notification::instance(); + $notification->issue_file($issue->id, $f3->get("file_id")); + } + + $f3->reroute("/issues/" . $issue->id); + } + + /** + * GET /issues/parent_ajax + * Load all matching issues + * + * @param \Base $f3 + */ + public function parent_ajax($f3) + { + if (!$f3->get("AJAX")) { + $f3->error(400); + } + + $user = $f3->get("user_obj"); + $searchFilter = ""; + if ($user->role != 'admin' && $f3->get('security.restrict_access')) { + // Determine the search string if user is not admin + $helper = \Helper\Dashboard::instance(); + $groupString = implode(",", array_merge($helper->getGroupIds(), [$user->id])); + $searchFilter = "(owner_id IN (" . $groupString . ")) AND "; + } + + $term = trim($f3->get('GET.q')); + $results = []; + + $issue = new \Model\Issue(); + if ((substr($term, 0, 1) == '#') && is_numeric(substr($term, 1))) { + $id = (int) substr($term, 1); + $issues = $issue->find([$searchFilter . 'id LIKE ?', $id . '%'], ['limit' => 20]); + + foreach ($issues as $row) { + $results[] = ['id' => $row->get('id'), 'text' => $row->get('name')]; + } + } elseif (is_numeric($term)) { + $id = (int) $term; + $issues = $issue->find([$searchFilter . '((id LIKE ?) OR (name LIKE ?))', $id . '%', '%' . $id . '%'], ['limit' => 20]); + + foreach ($issues as $row) { + $results[] = ['id' => $row->get('id'), 'text' => $row->get('name')]; + } + } else { + $issues = $issue->find([$searchFilter . 'name LIKE ?', '%' . addslashes($term) . '%'], ['limit' => 20]); + + foreach ($issues as $row) { + $results[] = ['id' => $row->get('id'), 'text' => $row->get('name')]; + } + } + + $this->_printJson(['results' => $results]); + } } diff --git a/app/controller/issues/project.php b/app/controller/issues/project.php new file mode 100644 index 00000000..36501a8a --- /dev/null +++ b/app/controller/issues/project.php @@ -0,0 +1,113 @@ +_userId = $this->_requireLogin(); + } + + /** + * GET /issues/project/@id + * Project Overview action + * + * @param \Base $f3 + * @param array $params + */ + public function overview($f3, $params) + { + // Load issue + $project = new \Model\Issue\Detail(); + $project->load($params["id"]); + if (!$project->id) { + $f3->error(404); + return; + } + + $f3->set("stats", $project->projectStats()); + + + // Find all nested issues + $model = new \Model\Issue\Detail(); + $parentMap = []; + $parents = [$project->id]; + do { + $pStr = implode(',', array_map('intval', $parents)); + $level = $model->find(["parent_id IN ($pStr) AND deleted_date IS NULL"]); + if (!$level) { + break; + } + $parents = []; + foreach ($level as $row) { + $parentMap[$row->parent_id][] = $row; + $parents[] = $row->id; + } + } while (true); + + /** + * Helper function for recursive tree rendering + * @param \Model\Issue $issue + * @param int $level + * @var callable $renderTree This function, required for recursive calls + */ + $renderTree = function (\Model\Issue &$issue, $level = 0) use ($parentMap, &$renderTree) { + if ($issue->id) { + $f3 = \Base::instance(); + $children = $parentMap[$issue->id] ?? []; + $hive = [ + "issue" => $issue, + "children" => $children, + "dict" => $f3->get("dict"), + "BASE" => $f3->get("BASE"), + "level" => $level, + "issue_type" => $f3->get("issue_type") + ]; + echo \Helper\View::instance()->render("issues/project/tree-item.html", "text/html", $hive); + if ($children) { + foreach ($children as $item) { + $renderTree($item, $level + 1); + } + } + } + }; + $f3->set("renderTree", $renderTree); + + + // Render view + $f3->set("project", $project); + $f3->set("title", $project->type_name . " #" . $project->id . ": " . $project->name . " - " . $f3->get("dict.project_overview")); + $this->_render("issues/project.html"); + } + + /** + * GET /issues/project/@id/files + * Get the file list for a project + * + * @param \Base $f3 + * @param array $params + */ + public function files($f3, array $params) + { + // Load issue + $project = new \Model\Issue(); + $project->load($params["id"]); + if (!$project->id) { + $f3->error(404); + return; + } + + $files = new \Model\Issue\File\Detail(); + $issueIds = $project->descendantIds(); + $idStr = implode(',', $issueIds); + + $f3->set("files", $files->find("issue_id IN ($idStr) AND deleted_date IS NULL")); + $this->_render('issues/project/files.html'); + } +} diff --git a/app/controller/tag.php b/app/controller/tag.php index 06c4137e..a05aab42 100644 --- a/app/controller/tag.php +++ b/app/controller/tag.php @@ -2,50 +2,52 @@ namespace Controller; -class Tag extends \Controller { - - protected $_userId; - - public function __construct() { - $this->_userId = $this->_requireLogin(); - } - - /** - * Tag index route (/tag/) - * @param \Base $f3 - */ - public function index($f3) { - $tag = new \Model\Issue\Tag; - $cloud = $tag->cloud(); - $f3->set("list", $cloud); - shuffle($cloud); - $f3->set("cloud", $cloud); - - $f3->set("title", $f3->get("dict.issue_tags")); - $this->_render("tag/index.html"); - } - - /** - * Single tag route (/tag/@tag) - * @param \Base $f3 - * @param array $params - */ - public function single($f3, $params) { - $tag = new \Model\Issue\Tag; - $tag->load(array("tag = ?", $params["tag"])); - - if(!$tag->id) { - $f3->error(404); - return; - } - - $issue = new \Model\Issue\Detail; - $issue_ids = implode(',', $tag->issues()); - - $f3->set("title", "#" . $params["tag"] . " - " . $f3->get("dict.issue_tags")); - $f3->set("tag", $tag); - $f3->set("issues.subset", $issue->find("id IN ($issue_ids)")); - $this->_render("tag/single.html"); - } - +class Tag extends \Controller +{ + protected $_userId; + + public function __construct() + { + $this->_userId = $this->_requireLogin(); + } + + /** + * Tag index route (/tag/) + * @param \Base $f3 + */ + public function index($f3) + { + $tag = new \Model\Issue\Tag(); + $cloud = $tag->cloud(); + $f3->set("list", $cloud); + shuffle($cloud); + $f3->set("cloud", $cloud); + + $f3->set("title", $f3->get("dict.issue_tags")); + $this->_render("tag/index.html"); + } + + /** + * Single tag route (/tag/@tag) + * @param \Base $f3 + * @param array $params + */ + public function single($f3, $params) + { + $tag = new \Model\Issue\Tag(); + $tag->load(["tag = ?", $params["tag"]]); + + if (!$tag->id) { + $f3->error(404); + return; + } + + $issue = new \Model\Issue\Detail(); + $issue_ids = implode(',', $tag->issues()); + + $f3->set("title", "#" . $params["tag"] . " - " . $f3->get("dict.issue_tags")); + $f3->set("tag", $tag); + $f3->set("issues.subset", $issue->find("id IN ($issue_ids) AND deleted_date IS NULL")); + $this->_render("tag/single.html"); + } } diff --git a/app/controller/taskboard.php b/app/controller/taskboard.php index e3da0bb7..19dc08f7 100644 --- a/app/controller/taskboard.php +++ b/app/controller/taskboard.php @@ -2,434 +2,415 @@ namespace Controller; -class Taskboard extends \Controller { - - public function __construct() { - $this->_userId = $this->_requireLogin(); - } - - /** - * Takes two dates formatted as YYYY-MM-DD and creates an - * inclusive array of the dates between the from and to dates. - * @param string $strDateFrom - * @param string $strDateTo - * @return array - */ - protected function _createDateRangeArray($strDateFrom, $strDateTo) { - $aryRange = array(); - - $iDateFrom = mktime(1,0,0,substr($strDateFrom,5,2),substr($strDateFrom,8,2),substr($strDateFrom,0,4)); - $iDateTo = mktime(1,0,0,substr($strDateTo,5,2),substr($strDateTo,8,2),substr($strDateTo,0,4)); - - if ($iDateTo >= $iDateFrom) { - $aryRange[] = date('Y-m-d', $iDateFrom); // first entry - while ($iDateFrom < $iDateTo) { - $iDateFrom += 86400; // add 24 hours - $aryRange[] = date('Y-m-d', $iDateFrom); - } - } - - return $aryRange; - } - - /** - * Get a list of users from a filter - * @param string $params URL Parameters - * @return array - */ - protected function _filterUsers($params) { - if($params["filter"] == "groups") { - $group_model = new \Model\User\Group(); - $groups_result = $group_model->find(array("user_id = ?", $this->_userId)); - $filter_users = array($this->_userId); - foreach($groups_result as $g) { - $filter_users[] = $g["group_id"]; - } - $groups = implode(",", $filter_users); - $users_result = $group_model->find("group_id IN ({$groups})"); - foreach($users_result as $u) { - $filter_users[] = $u["user_id"]; - } - } elseif($params["filter"] == "me") { - $filter_users = array($this->_userId); - } elseif(is_numeric($params["filter"])) { - $user = new \Model\User(); - $user->load($params["filter"]); - if ($user->role == 'group') { - $group_model = new \Model\User\Group(); - $users_result = $group_model->find(array("group_id = ?", $user->id)); - $filter_users = array(intval($params["filter"])); - foreach($users_result as $u) { - $filter_users[] = $u["user_id"]; - } - } else { - $filter_users = array($params["filter"]); - } - } elseif($params["filter"] == "all") { - return array(); - } else { - return array($this->_userId); - } - return $filter_users; - } - - /** - * View a taskboard - * - * @param \Base $f3 - * @param array $params - */ - public function index($f3, $params) { - $sprint = new \Model\Sprint(); - - // Load current sprint if no sprint ID is given - if(!intval($params["id"])) { - $localDate = date('Y-m-d', \Helper\View::instance()->utc2local()); - $sprint->load(array("? BETWEEN start_date AND end_date", $localDate)); - if(!$sprint->id) { - $f3->error(404); - return; - } - } - - // Default to showing group tasks - if(empty($params["filter"])) { - $params["filter"] = "groups"; - } - - // Load the requested sprint - if(!$sprint->id) { - $sprint->load($params["id"]); - if(!$sprint->id) { - $f3->error(404); - return; - } - } - - $f3->set("sprint", $sprint); - $f3->set("title", $sprint->name . " " . date('n/j', strtotime($sprint->start_date)) . "-" . date('n/j', strtotime($sprint->end_date))); - $f3->set("menuitem", "backlog"); - - // Get list of all users in the user's groups - $filter_users = $this->_filterUsers($params); - - // Load issue statuses - $status = new \Model\Issue\Status(); - $statuses = $status->find(array('taskboard > 0'), array('order' => 'taskboard_sort ASC')); - $mapped_statuses = array(); - $visible_status_ids = array(); - $column_count = 0; - foreach($statuses as $s) { - $visible_status_ids[] = $s->id; - $mapped_statuses[$s->id] = $s; - $column_count += $s->taskboard; - } - - $visible_status_ids = implode(",", $visible_status_ids); - $f3->set("statuses", $mapped_statuses); - $f3->set("column_count", $column_count); - - // Load issue priorities - $priority = new \Model\Issue\Priority(); - $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); - - // Load project list - $issue = new \Model\Issue\Detail(); - - // Find all visible tasks - $tasks = $issue->find(array( - "sprint_id = ? AND type_id != ? AND deleted_date IS NULL AND status IN ($visible_status_ids)" - . (empty($filter_users) ? "" : " AND owner_id IN (" . implode(",", $filter_users) . ")"), - $sprint->id, $f3->get("issue_type.project") - ), array("order" => "priority DESC")); - $task_ids = array(); - $parent_ids = array(0); - foreach($tasks as $task) { - $task_ids[] = $task->id; - if($task->parent_id) { - $parent_ids[] = $task->parent_id; - } - } - $task_ids_str = implode(",", $task_ids); - $parent_ids_str = implode(",", $parent_ids); - $f3->set("tasks", $task_ids_str); - - // Find all visible projects or parent tasks - $projects = $issue->find(array( - "id IN ($parent_ids_str) OR (sprint_id = ? AND type_id = ? AND deleted_date IS NULL" - . (empty($filter_users) ? ")" : " AND owner_id IN (" . implode(",", $filter_users) . "))"), - $sprint->id, $f3->get("issue_type.project") - ), array("order" => "owner_id ASC, priority DESC")); - - // Sort projects if a filter is given - if(!empty($params["filter"]) && is_numeric($params["filter"])) { - $sortModel = new \Model\Issue\Backlog; - $sortModel->load(array("user_id = ? AND sprint_id = ?", $params["filter"], $sprint->id)); - $sortArray = array(); - if($sortModel->id) { - $sortArray = json_decode($sortModel->issues); - usort($projects, function(\Model\Issue $a, \Model\Issue $b) use($sortArray) { - $ka = array_search($a->id, $sortArray); - $kb = array_search($b->id, $sortArray); - if($ka === false && $kb !== false) { - return -1; - } - if($ka !== false && $kb === false) { - return 1; - } - if($ka === $kb) { - return 0; - } - if($ka > $kb) { - return 1; - } - if($ka < $kb) { - return -1; - } - }); - } - } - - // Build multidimensional array of all tasks and projects - $taskboard = array(); - foreach($projects as $project) { - - // Build array of statuses to put tasks under - $columns = array(); - foreach($statuses as $status) { - $columns[$status["id"]] = array(); - } - - // Add current project's tasks - foreach ($tasks as $task) { - if($task->parent_id == $project->id || $project->id == 0 && (!$task->parent_id || !in_array($task->parent_id, $parent_ids))) { - $columns[$task->status][] = $task; - } - } - - // Add hierarchical structure to taskboard array - $taskboard[] = array( - "project" => $project, - "columns" => $columns - ); - - } - - $f3->set("taskboard", array_values($taskboard)); - $f3->set("filter", $params["filter"]); - - // Get user list for select - $users = new \Model\User(); - $f3->set("users", $users->getAll()); - $f3->set("groups", $users->getAllGroups()); - - $this->_render("taskboard/index.html"); - } - - /** - * Load the burndown chart data - * - * @param \Base $f3 - * @param array $params - */ - public function burndown($f3, $params) { - $sprint = new \Model\Sprint; - $sprint->load($params["id"]); - - if(!$sprint->id) { - $f3->error(404); - return; - } - - $visible_tasks = explode(",", $params["tasks"]); - - // Visible tasks must have at least one key - if (empty($visible_tasks)) { - $visible_tasks = array(0); - } - - // Get today's date - $today = date('Y-m-d'); - $today = $today . " 23:59:59"; - - // Check to see if the sprint is completed - if ($today < strtotime($sprint->end_date . ' + 1 day')) { - $burnComplete = 0; - $burnDates = $this->_createDateRangeArray($sprint->start_date, $today); - $remainingDays = $this->_createDateRangeArray($today, $sprint->end_date); - } else { - $burnComplete = 1; - $burnDates = $this->_createDateRangeArray($sprint->start_date, $sprint->end_date); - $remainingDays = array(); - } - - $burnDays = array(); - $burnDatesCount = count($burnDates); - - $db = $f3->get("db.instance"); - $visible_tasks_str = implode(",", $visible_tasks); - $query_initial = - "SELECT SUM(IFNULL(i.hours_total, i.hours_remaining)) AS remaining - FROM issue i - WHERE i.created_date < :date - AND i.id IN (" . implode(",", $visible_tasks) . ")"; - $query_daily = - "SELECT SUM(IF(f.id IS NULL, IFNULL(i.hours_total, i.hours_remaining), f.new_value)) AS remaining - FROM issue_update_field f - JOIN issue_update u ON u.id = f.issue_update_id - JOIN ( - SELECT MAX(u.id) AS max_id - FROM issue_update u - JOIN issue_update_field f ON f.issue_update_id = u.id - WHERE f.field = 'hours_remaining' - AND u.created_date < :date - AND u.issue_id IN ($visible_tasks_str) - GROUP BY u.issue_id - ) a ON a.max_id = u.id - RIGHT JOIN issue i ON i.id = u.issue_id - WHERE (f.field = 'hours_remaining' OR f.field IS NULL) - AND i.created_date < :date - AND i.id IN ($visible_tasks_str)"; - - $i = 1; - foreach($burnDates as $date) { - - // Get total_hours, which is the initial amount entered on each task, and cache this query - if($i == 1) { - $result = $db->exec($query_initial, array(":date" => $sprint->start_date), 2592000); - $burnDays[$date] = $result[0]; - } - - // Get between day values and cache them... this also will get the last day of completed sprints so they will be cached - elseif ($i < ($burnDatesCount - 1) || $burnComplete) { - $result = $db->exec($query_daily, array(":date" => $date . " 23:59:59"), 2592000); - $burnDays[$date] = $result[0]; - } - - // Get the today's info and don't cache it - else { - $result = $db->exec($query_daily, array(":date" => $date . " 23:59:59")); - $burnDays[$date] = $result[0]; - } - - $i++; - } - - // Add in empty days - if(!$burnComplete) { - $i = 0; - foreach($remainingDays as $day) { - if($i != 0){ - $burnDays[$day] = NULL; - } - $i++; - } - } - - // Reformat the date and remove weekends - $i = 0; - foreach($burnDays as $burnKey => $burnDay) { - - $weekday = date("D", strtotime($burnKey)); - $weekendDays = array("Sat","Sun"); - - if(!in_array($weekday, $weekendDays)) { - $newDate = date("M j", strtotime($burnKey)); - $burnDays[$newDate] = $burnDays[$burnKey]; - unset($burnDays[$burnKey]); - } else { // Remove weekend days - unset($burnDays[$burnKey]); - } - - $i++; - } - - $this->_printJson($burnDays); - } - - /** - * Add a new task - * - * @param \Base $f3 - */ - public function add($f3) { - $post = $f3->get("POST"); - $post['sprint_id'] = $post['sprintId']; - $post['name'] = $post['title']; - $post['owner_id'] = $post['assigned']; - $post['due_date'] = $post['dueDate']; - $post['parent_id'] = $post['storyId']; - $issue = \Model\Issue::create($post); - $this->_printJson($issue->cast() + array("taskId" => $issue->id)); - } - - /** - * Update an existing task - * - * @param \Base $f3 - */ - public function edit($f3) { - $post = $f3->get("POST"); - $issue = new \Model\Issue(); - $issue->load($post["taskId"]); - if(!empty($post["receiver"])) { - if($post["receiver"]["story"]) { - $issue->parent_id = $post["receiver"]["story"]; - } - $issue->status = $post["receiver"]["status"]; - $status = new \Model\Issue\Status(); - $status->load($issue->status); - if($status->closed) { - if(!$issue->closed_date) { - $issue->closed_date = $this->now(); - } - } else { - $issue->closed_date = null; - } - } else { - $issue->name = $post["title"]; - $issue->description = $post["description"]; - $issue->owner_id = $post["assigned"]; - $issue->hours_remaining = $post["hours"]; - $issue->hours_spent += $post["hours_spent"]; - if(!empty($post["hours_spent"]) && !empty($post["burndown"])) { - $issue->hours_remaining -= $post["hours_spent"]; - } - if($issue->hours_remaining < 0) { - $issue->hours_remaining = 0; - } - if(!empty($post["dueDate"])) { - $issue->due_date = date("Y-m-d", strtotime($post["dueDate"])); - } else { - $issue->due_date = null; - } - if(!empty($post["repeat_cycle"])) { - $issue->repeat_cycle = $post["repeat_cycle"]; - } - $issue->priority = $post["priority"]; - if(!empty($post["storyId"])) { - $issue->parent_id = $post["storyId"]; - } - $issue->title = $post["title"]; - } - - if(!empty($post["comment"])) { - $comment = new \Model\Issue\Comment; - $comment->user_id = $this->_userId; - $comment->issue_id = $issue->id; - if(!empty($post["hours_spent"])) { - $comment->text = trim($post["comment"]) . sprintf(" (%s %s spent)", $post["hours_spent"], $post["hours_spent"] == 1 ? "hour" : "hours"); - } else { - $comment->text = $post["comment"]; - } - $comment->created_date = $this->now(); - $comment->save(); - $issue->update_comment = $comment->id; - } - - $issue->save(); - - $this->_printJson($issue->cast() + array("taskId" => $issue->id)); - } - +class Taskboard extends \Controller +{ + protected $_userId; + + public function __construct() + { + $this->_userId = $this->_requireLogin(); + } + + /** + * Get a list of users from a filter + * @param string $params URL Parameters + * @return array + */ + protected function _filterUsers($params) + { + if ($params["filter"] == "groups") { + $group_model = new \Model\User\Group(); + $groups_result = $group_model->find(["user_id = ?", $this->_userId]); + $filter_users = [$this->_userId]; + foreach ($groups_result as $g) { + $filter_users[] = $g["group_id"]; + } + $groups = implode(",", $filter_users); + $users_result = $group_model->find("group_id IN ({$groups})"); + foreach ($users_result as $u) { + $filter_users[] = $u["user_id"]; + } + } elseif ($params["filter"] == "me") { + $filter_users = [$this->_userId]; + } elseif (is_numeric($params["filter"])) { + $user = new \Model\User(); + $user->load($params["filter"]); + if ($user->role == 'group') { + \Base::instance()->set("filterGroup", $user); + $group_model = new \Model\User\Group(); + $users_result = $group_model->find(["group_id = ?", $user->id]); + $filter_users = [intval($params["filter"])]; + foreach ($users_result as $u) { + $filter_users[] = $u["user_id"]; + } + } else { + $filter_users = [$params["filter"]]; + } + } elseif ($params["filter"] == "all") { + return []; + } else { + return [$this->_userId]; + } + return $filter_users; + } + + /** + * GET /taskboard + * GET /taskboard/@id + * GET /taskboard/@id/@filter + * + * View a taskboard + * + * @param \Base $f3 + * @param array $params + */ + public function index($f3, $params) + { + $sprint = new \Model\Sprint(); + + // Load current sprint if no sprint ID is given + if (empty($params["id"]) || !intval($params["id"])) { + $localDate = date('Y-m-d', \Helper\View::instance()->utc2local()); + $sprint->load(["? BETWEEN start_date AND end_date", $localDate]); + if (!$sprint->id) { + $f3->error(404); + return; + } + } + + // Default to showing group tasks + if (empty($params["filter"])) { + $params["filter"] = "groups"; + } + + // Load the requested sprint + if (!$sprint->id) { + $sprint->load($params["id"]); + if (!$sprint->id) { + $f3->error(404); + return; + } + } + + $f3->set("sprint", $sprint); + $f3->set("title", $sprint->name . " " . date('n/j', strtotime($sprint->start_date)) . "-" . date('n/j', strtotime($sprint->end_date))); + $f3->set("menuitem", "backlog"); + + // Get list of all users in the user's groups + $filter_users = $this->_filterUsers($params); + + // Load issue statuses + $status = new \Model\Issue\Status(); + $statuses = $status->find(['taskboard > 0'], ['order' => 'taskboard_sort ASC']); + $mapped_statuses = []; + $visible_status_ids = []; + $column_count = 0; + foreach ($statuses as $s) { + $visible_status_ids[] = $s->id; + $mapped_statuses[$s->id] = $s; + $column_count += $s->taskboard; + } + + $visible_status_ids = implode(",", $visible_status_ids); + $f3->set("statuses", $mapped_statuses); + $f3->set("column_count", $column_count); + + // Load issue priorities + $priority = new \Model\Issue\Priority(); + $f3->set("priorities", $priority->find(null, ["order" => "value DESC"], $f3->get("cache_expire.db"))); + + // Load project list + $issue = new \Model\Issue\Detail(); + + // Determine type filtering + $type = new \Model\Issue\Type(); + $projectTypes = $type->find(["role = ?", "project"]); + $f3->set("project_types", $projectTypes); + if ($f3->get("GET.type_id")) { + $typeIds = array_filter($f3->split($f3->get("GET.type_id")), "is_numeric"); + } else { + $typeIds = []; + foreach ($projectTypes as $type) { + $typeIds[] = $type->id; + } + } + $typeStr = implode(",", $typeIds); + sort($typeIds, SORT_NUMERIC); + + // Find all visible tasks + $tasks = $issue->find([ + "sprint_id = ? AND type_id NOT IN ($typeStr) AND deleted_date IS NULL AND status IN ($visible_status_ids)" + . (empty($filter_users) ? "" : " AND owner_id IN (" . implode(",", $filter_users) . ")"), + $sprint->id + ], ["order" => "priority DESC, id ASC"]); + $task_ids = []; + $parent_ids = [0]; + foreach ($tasks as $task) { + $task_ids[] = $task->id; + if ($task->parent_id) { + $parent_ids[] = $task->parent_id; + } + } + $task_ids_str = implode(",", $task_ids); + $parent_ids_str = implode(",", $parent_ids); + $f3->set("tasks", $task_ids_str); + + // Find all visible projects or parent tasks if no type filter is given + $queryArray = [ + "(id IN ($parent_ids_str) AND type_id IN ($typeStr)) OR (sprint_id = ? AND type_id IN ($typeStr) AND deleted_date IS NULL" + . (empty($filter_users) ? ")" : " AND owner_id IN (" . implode(",", $filter_users) . "))"), + $sprint->id + ]; + $projects = $issue->find($queryArray, ["order" => "owner_id ASC, priority DESC"]); + + // Sort projects if a filter is given + $sortModel = new \Model\Issue\Backlog(); + $sortOrder = $sortModel->load(["sprint_id = ?", $sprint->id]); + if ($sortOrder) { + $sortArray = json_decode($sortOrder->issues, null, 512, JSON_THROW_ON_ERROR) ?: []; + $sortArray = array_unique($sortArray); + usort($projects, function (\Model\Issue $a, \Model\Issue $b) use ($sortArray) { + $ka = array_search($a->id, $sortArray); + $kb = array_search($b->id, $sortArray); + if ($ka === false && $kb !== false) { + return -1; + } + if ($ka !== false && $kb === false) { + return 1; + } + return $ka <=> $kb; + }); + } + + // Build multidimensional array of all tasks and projects + $taskboard = []; + foreach ($projects as $project) { + // Build array of statuses to put tasks under + $columns = []; + foreach ($statuses as $status) { + $columns[$status["id"]] = []; + } + + // Add current project's tasks + foreach ($tasks as $task) { + if ($task->parent_id == $project->id || $project->id == 0 && (!$task->parent_id || !in_array($task->parent_id, $parent_ids))) { + $columns[$task->status][] = $task; + } + } + + // Add hierarchical structure to taskboard array + $taskboard[] = [ + "project" => $project, + "columns" => $columns + ]; + } + + $f3->set("type_ids", $typeIds); + $f3->set("taskboard", array_values($taskboard)); + $f3->set("filter", $params["filter"]); + + // Get user list for select + $users = new \Model\User(); + $f3->set("users", $users->getAll()); + $f3->set("groups", $users->getAllGroups()); + + // Get next/previous sprints + $f3->set("nextSprint", $sprint->findone(['start_date >= ?', $sprint->end_date], ['order' => 'start_date asc'])); + $f3->set("prevSprint", $sprint->findone(['end_date <= ?', $sprint->start_date], ['order' => 'end_date desc'])); + + $this->_render("taskboard/index.html"); + } + + /** + * GET /taskboard/@id/burndown + * GET /taskboard/@id/burndown/@filter + * + * Load the hourly burndown chart data + * + * @param \Base $f3 + * @param array $params + */ + public function burndown($f3, $params) + { + $sprint = new \Model\Sprint(); + $sprint->load($params["id"]); + + if (!$sprint->id) { + $f3->error(404); + return; + } + + $db = $f3->get("db.instance"); + + $user = new \Model\User(); + $user->load(["id = ?", $params["filter"]]); + if (!$user->id) { + $f3->error(404); + return; + } + + $query = " + SELECT SUM(IFNULL(f.new_value, IFNULL(i.hours_total, i.hours_remaining))) AS remaining + FROM issue_update_field f + JOIN issue_update u ON u.id = f.issue_update_id + JOIN ( + SELECT MAX(u.id) AS max_id + FROM issue_update u + JOIN issue_update_field f ON f.issue_update_id = u.id + JOIN issue i ON i.id = u.issue_id + JOIN user_group g ON g.user_id = i.owner_id OR g.group_id = i.owner_id + WHERE f.field = 'hours_remaining' + AND i.sprint_id = :sprint1 + AND u.created_date < :date1 + AND g.group_id = :user1 + GROUP BY u.issue_id + ) a ON a.max_id = u.id + RIGHT JOIN ( + SELECT i.* + FROM issue i + JOIN user_group g ON g.user_id = i.owner_id OR g.group_id = i.owner_id + WHERE i.sprint_id = :sprint2 + AND g.group_id = :user2 + ) i ON i.id = u.issue_id + WHERE (f.field = 'hours_remaining' OR f.field IS NULL) + AND i.created_date < :date2"; + + $start = strtotime($sprint->getFirstWeekday()); + $end = min(strtotime($sprint->getLastWeekday() . " 23:59:59"), time()); + + $return = []; + $cur = $start; + $helper = \Helper\View::instance(); + $offset = $helper->timeoffset(); + while ($cur < $end) { + $date = date("Y-m-d H:i:00", $cur); + $utc = date("Y-m-d H:i:s", $cur - $offset); + $return[$date] = round($db->exec($query, [ + ":date1" => $utc, + ":date2" => $utc, + ":sprint1" => $sprint->id, + ":sprint2" => $sprint->id, + ":user1" => $user->id, + ":user2" => $user->id, + ])[0]["remaining"], 2); + $cur += 3600; + } + + $this->_printJson($return); + } + + /** + * POST /taskboard/saveManHours + * + * Save man hours for a group/user + * + * @param \Base $f3 + */ + public function saveManHours($f3) + { + $this->validateCsrf(); + $user = new \Model\User(); + $user->load(["id = ?", $f3->get("POST.user_id")]); + if (!$user->id) { + $f3->error(404); + } + if ($user->id != $this->_userId && $user->role != "group") { + $f3->error(403); + } + $user->option("man_hours", floatval($f3->get("POST.man_hours"))); + $user->save(); + } + + /** + * POST /taskboard/add + * + * Add a new task + * + * @param \Base $f3 + */ + public function add($f3) + { + $this->validateCsrf(); + $post = $f3->get("POST"); + $post['sprint_id'] = $post['sprintId']; + $post['name'] = $post['title']; + $post['owner_id'] = $post['assigned'] ?: null; + $post['due_date'] = $post['dueDate']; + $post['parent_id'] = $post['storyId']; + $issue = \Model\Issue::create($post); + $this->_printJson($issue->cast() + ["taskId" => $issue->id]); + } + + /** + * POST /taskboard/edit/@id + * + * Update an existing task + * + * @param \Base $f3 + */ + public function edit($f3) + { + $this->validateCsrf(); + $post = $f3->get("POST"); + $issue = new \Model\Issue(); + $issue->load($post["taskId"]); + if (!empty($post["receiver"])) { + if ($post["receiver"]["story"]) { + $issue->parent_id = $post["receiver"]["story"]; + } + $issue->status = $post["receiver"]["status"]; + $status = new \Model\Issue\Status(); + $status->load($issue->status); + if ($status->closed) { + if (!$issue->closed_date) { + $issue->closed_date = $this->now(); + } + } else { + $issue->closed_date = null; + } + } else { + $issue->name = $post["title"]; + $issue->description = $post["description"]; + $issue->owner_id = $post["assigned"] ?: null; + $issue->hours_remaining = floatval($post["hours"] ?? null) ?: 0; + $issue->hours_spent += floatval($post["hours_spent"] ?? null) ?: 0; + if (!empty($post["hours_spent"]) && !empty($post["burndown"])) { + $issue->hours_remaining -= $post["hours_spent"]; + } + if (!$issue->hours_remaining || $issue->hours_remaining < 0) { + $issue->hours_remaining = 0; + } + if (!empty($post["dueDate"])) { + $issue->due_date = date("Y-m-d", strtotime($post["dueDate"])); + } else { + $issue->due_date = null; + } + if (isset($post["repeat_cycle"])) { + $issue->repeat_cycle = $post["repeat_cycle"] ?: null; + } + $issue->priority = $post["priority"]; + if (!empty($post["storyId"])) { + $issue->parent_id = $post["storyId"]; + } + $issue->title = $post["title"]; + } + + if (!empty($post["comment"])) { + $comment = new \Model\Issue\Comment(); + $comment->user_id = $this->_userId; + $comment->issue_id = $issue->id; + if (!empty($post["hours_spent"])) { + $comment->text = trim($post["comment"]) . sprintf(" (%s %s spent)", $post["hours_spent"], $post["hours_spent"] == 1 ? "hour" : "hours"); + } else { + $comment->text = $post["comment"]; + } + $comment->created_date = $this->now(); + $comment->save(); + $issue->update_comment = $comment->id; + } + + $issue->save(); + + $this->_printJson($issue->cast() + ["taskId" => $issue->id]); + } } diff --git a/app/controller/user.php b/app/controller/user.php index a7987e91..21c689ec 100644 --- a/app/controller/user.php +++ b/app/controller/user.php @@ -2,374 +2,410 @@ namespace Controller; -class User extends \Controller { - - protected $_userId; - - private $_languages; - - public function __construct() { - $this->_userId = $this->_requireLogin(); - $this->_languages = array( - "en" => \ISO::LC_en, - "en-GB" => \ISO::LC_en . " (Great Britain)", - "es" => \ISO::LC_es . " (Español)", - "pt" => \ISO::LC_pt . " (Português)", - "it" => \ISO::LC_it . " (Italiano)", - "ru" => \ISO::LC_ru . " (Pу́сский)", - "nl" => \ISO::LC_nl . " (Nederlands)", - "de" => \ISO::LC_de . " (Deutsch)", - "cs" => \ISO::LC_cs . " (Češka)", - "zh" => \ISO::LC_zh . " (中国)", - "ja" => \ISO::LC_ja . " (日本語)", - ); - } - - /** - * GET /user/dashboard User dashboard - * - * @param \Base $f3 - * @throws \Exception - */ - public function dashboard($f3) { - $dashboard = $f3->get("user_obj")->option("dashboard"); - if(!$dashboard) { - $dashboard = array( - "left" => array("projects", "subprojects", "bugs", "repeat_work", "watchlist"), - "right" => array("tasks") - ); - } - - // Load dashboard widget data - $allWidgets = array("projects", "subprojects", "tasks", "bugs", "repeat_work", "watchlist", "my_comments", "recent_comments", "open_comments", "issue_tree"); - $helper = \Helper\Dashboard::instance(); - foreach($dashboard as $widgets) { - foreach($widgets as $widget) { - if(is_callable(array($helper, $widget))) { - $f3->set($widget, $helper->$widget()); - } else { - $f3->set("error", "Widget '{$widget}' is not available."); - } - unset($allWidgets[array_search($widget, $allWidgets)]); - } - } - $f3->set("unused_widgets", $allWidgets); - - // Get current sprint if there is one - $sprint = new \Model\Sprint; - $localDate = date('Y-m-d', \Helper\View::instance()->utc2local()); - $sprint->load(array("? BETWEEN start_date AND end_date", $localDate)); - $f3->set("sprint", $sprint); - - $f3->set("dashboard", $dashboard); - $f3->set("menuitem", "index"); - $this->_render("user/dashboard.html"); - } - - /** - * POST /user/dashboard - * - * @param \Base $f3 - */ - public function dashboardPost($f3) { - $user = $f3->get("user_obj"); - if($f3->get("POST.action") == "add") { - $widgets = $user->option("dashboard"); - foreach($f3->get("POST.widgets") as $widget) { - $widgets["left"][] = $widget; - } - } else { - $widgets = json_decode($f3->get("POST.widgets")); - } - $user->option("dashboard", $widgets); - $user->save(); - if($f3->get("AJAX")) { - $this->_printJson($widgets); - } else { - $f3->reroute("/"); - } - } - - private function _loadThemes() { - $f3 = \Base::instance(); - - // Get theme list - $hidden_themes = array("backlog", "style", "taskboard", "datepicker", "jquery-ui-1.10.3", "bootstrap-tagsinput", "emote", "fontawesome", "font-awesome", "simplemde"); - $themes = array(); - foreach (glob("css/*.css") as $file) { - $name = pathinfo($file, PATHINFO_FILENAME); - if(!in_array($name, $hidden_themes)) { - $themes[] = $name; - } - } - - $f3->set("themes", $themes); - return $themes; - } - - /** - * GET /user - * - * @param \Base $f3 - */ - public function account($f3) { - $f3->set("title", $f3->get("dict.my_account")); - $f3->set("menuitem", "user"); - $f3->set("languages", $this->_languages); - $this->_loadThemes(); - $this->_render("user/account.html"); - } - - /** - * POST /user - * - * @param \Base $f3 - * @throws \Exception - */ - public function save($f3) { - $f3 = \Base::instance(); - $post = array_map("trim", $f3->get("POST")); - - $user = new \Model\User(); - $user->load($this->_userId); - - if(!empty($post["old_pass"])) { - - $security = \Helper\Security::instance(); - - // Update password - if($security->hash($post["old_pass"], $user->salt) == $user->password) { - $min = $f3->get("security.min_pass_len"); - if(strlen($post["new_pass"]) >= $min) { - if($post["new_pass"] == $post["new_pass_confirm"]) { - $user->salt = $security->salt(); - $user->password = $security->hash($post["new_pass"], $user->salt); - $f3->set("success", "Password updated successfully."); - } else { - $f3->set("error", "New passwords do not match"); - } - } else { - $f3->set("error", "New password must be at least {$min} characters."); - } - } else { - $f3->set("error", "Current password entered is not valid."); - } - - } elseif(!empty($post["action"]) && $post["action"] == "options") { - - // Update option values - $user->option("disable_mde", !empty($post["disable_mde"])); - - } else { - - // Update profile - if(!empty($post["name"])) { - $user->name = filter_var($post["name"], FILTER_SANITIZE_STRING); - } else { - $error = "Please enter your name."; - } - if(preg_match("/^([\p{L}\.\-\d]+)@([\p{L}\-\.\d]+)((\.(\p{L})+)+)$/im", $post["email"])) { - $user->email = $post["email"]; - } else { - $error = $post["email"] . " is not a valid email address."; - } - if(empty($error) && ctype_xdigit(ltrim($post["task_color"], "#"))) { - $user->task_color = ltrim($post["task_color"], "#"); - } elseif(empty($error)) { - $error = $post["task_color"] . " is not a valid color code."; - } - - if(empty($post["theme"])) { - $user->theme = null; - } else { - $user->theme = $post["theme"]; - } - - if(empty($post["language"])) { - $user->language = null; - } else { - $user->language = $post["language"]; - } - - if(empty($error)) { - $f3->set("success", "Profile updated successfully."); - } else { - $f3->set("error", $error); - } - - } - - $user->save(); - $f3->set("title", $f3->get("dict.my_account")); - $f3->set("menuitem", "user"); - - // Use new user values for page - $user->loadCurrent(); - - $f3->set("languages", $this->_languages); - $this->_loadThemes(); - - $this->_render("user/account.html"); - } - - /** - * POST /user/avatar - * - * @param \Base $f3 - * @throws \Exception - */ - public function avatar($f3) { - $f3 = \Base::instance(); - - $user = new \Model\User(); - $user->load($this->_userId); - if(!$user->id) { - $f3->error(404); - return; - } - - $web = \Web::instance(); - - $f3->set("UPLOADS",'uploads/avatars/'); - if(!is_dir($f3->get("UPLOADS"))) { - mkdir($f3->get("UPLOADS"), 0777, true); - } - $overwrite = true; - $slug = true; - - //Make a good name - $parts = pathinfo($_FILES['avatar']['name']); - $_FILES['avatar']['name'] = $user->id . "-" . substr(uniqid(), 0, 4) . "." . $parts["extension"]; - $f3->set("avatar_filename", $_FILES['avatar']['name']); - - $web->receive( - function($file) use($f3, $user) { - if($file['size'] > $f3->get("files.maxsize")) { - return false; - } - - $user->avatar_filename = $f3->get("avatar_filename"); - $user->save(); - return true; - }, - $overwrite, - $slug - ); - - // Clear cached profile picture data - $cache = \Cache::instance(); - // @1x - $cache->clear($f3->hash("GET /avatar/48/{$user->id}.png") . ".url"); - $cache->clear($f3->hash("GET /avatar/96/{$user->id}.png") . ".url"); - $cache->clear($f3->hash("GET /avatar/128/{$user->id}.png") . ".url"); - // @2x - $cache->clear($f3->hash("GET /avatar/192/{$user->id}.png") . ".url"); - $cache->clear($f3->hash("GET /avatar/256/{$user->id}.png") . ".url"); - - $f3->reroute("/user"); - } - - /** - * GET /user/@username - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single($f3, $params) { - $this->_requireLogin(); - - $user = new \Model\User; - $user->load(array("username = ?", $params["username"])); - - if($user->id && (!$user->deleted_date || $f3->get("user.rank") >= 3)) { - $f3->set("title", $user->name); - $f3->set("this_user", $user); - - // Extra arrays required for bulk update - $status = new \Model\Issue\Status; - $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); - - $f3->set("users", $user->getAll()); - $f3->set("groups", $user->getAllGroups()); - - $priority = new \Model\Issue\Priority; - $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); - - $type = new \Model\Issue\Type; - $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); - - $issue = new \Model\Issue\Detail; - $f3->set("created_issues", $issue->paginate(0, 200, array("status_closed = '0' AND deleted_date IS NULL AND author_id = ?", $user->id), - array("order" => "priority DESC, due_date DESC"))); - $f3->set("assigned_issues", $issue->paginate(0, 200, array("status_closed = '0' AND deleted_date IS NULL AND owner_id = ?", $user->id), - array("order" => "priority DESC, due_date DESC"))); - $f3->set("overdue_issues", $issue->paginate(0, 200, array("status_closed = '0' AND deleted_date IS NULL AND owner_id = ? AND due_date IS NOT NULL AND due_date < ?", - $user->id, date("Y-m-d", \Helper\View::instance()->utc2local())), array("order" => "due_date ASC"))); - - $this->_render("user/single.html"); - } else { - $f3->error(404); - } - } - - /** - * Convert a flat issue array to a tree array. Child issues are added to - * the 'children' key in each issue. - * @param array $array Flat array of issues, including all parents needed - * @return array Tree array where each issue contains its child issues - */ - protected function _buildTree($array) { - $tree = array(); - - // Create an associative array with each key being the ID of the item - foreach($array as $k => &$v) { - $tree[$v['id']] = &$v; - } - - // Loop over the array and add each child to their parent - foreach($tree as $k => &$v) { - if(empty($v['parent_id'])) { - continue; - } - $tree[$v['parent_id']]['children'][] = &$v; - } - - // Loop over the array again and remove any items that don't have a parent of 0; - foreach($tree as $k => &$v) { - if(empty($v['parent_id'])) { - continue; - } - unset($tree[$k]); - } - - return $tree; - } - - /** - * GET /user/@username/tree - * - * @param \Base $f3 - * @param array $params - * @throws \Exception - */ - public function single_tree($f3, $params) { - $this->_requireLogin(); - - $user = new \Model\User; - $user->load(array("username = ? AND deleted_date IS NULL", $params["username"])); - - if($user->id) { - $f3->set("title", $user->name); - $f3->set("this_user", $user); - - $tree = \Helper\Dashboard::instance()->issue_tree(); - - $f3->set("issues", $tree); - $this->_render($f3->get("AJAX") ? "user/single/tree/ajax.html" : "user/single/tree.html"); - } else { - $f3->error(404); - } - } - +class User extends \Controller +{ + protected $_userId; + + private $_languages; + + public function __construct() + { + $this->_userId = $this->_requireLogin(); + $this->_languages = [ + "en" => \ISO::LC_en, + "en-GB" => \ISO::LC_en . " (Great Britain)", + "es" => \ISO::LC_es . " (Español)", + "fr" => \ISO::LC_fr . " (Français)", + "pl" => \ISO::LC_pl . " (Polszczyzna)", + "pt" => \ISO::LC_pt . " (Português)", + "it" => \ISO::LC_it . " (Italiano)", + "ru" => \ISO::LC_ru . " (Pу́сский)", + "nl" => \ISO::LC_nl . " (Nederlands)", + "de" => \ISO::LC_de . " (Deutsch)", + "cs" => \ISO::LC_cs . " (Češka)", + "et" => \ISO::LC_et . " (Eesti)", + "zh" => \ISO::LC_zh . " (中国)", + "ja" => \ISO::LC_ja . " (日本語)", + ]; + } + + /** + * GET /user/dashboard + * User dashboard + * + * @param \Base $f3 + * @throws \Exception + */ + public function dashboard($f3) + { + $dashboard = $f3->get("user_obj")->option("dashboard"); + $helper = \Helper\Dashboard::instance(); + if (!$dashboard || !is_array($dashboard)) { + $dashboard = $helper->defaultConfig; + } + + // Load dashboard widget data + $allWidgets = $helper->allWidgets; + $missing = []; + foreach ($dashboard as $k => $widgets) { + foreach ($widgets as $l => $widget) { + if (in_array($widget, $allWidgets)) { + $f3->set($widget, $helper->$widget()); + } else { + $f3->set("error", "Some dashboard widgets cannot be displayed."); + $missing[] = [$k, $l]; + } + unset($allWidgets[array_search($widget, $allWidgets)]); + } + } + foreach ($missing as $kl) { + unset($dashboard[$kl[0]][$kl[1]]); + } + $f3->set("unused_widgets", $allWidgets); + + // Get current sprint if there is one + $sprint = new \Model\Sprint(); + $localDate = date('Y-m-d', \Helper\View::instance()->utc2local()); + $sprint->load(["? BETWEEN start_date AND end_date", $localDate]); + $f3->set("sprint", $sprint); + + $f3->set("dashboard", $dashboard); + $f3->set("menuitem", "index"); + $this->_render("user/dashboard.html"); + } + + /** + * POST /user/dashboard + * Save dashboard widget selections + * + * @param \Base $f3 + */ + public function dashboardPost($f3) + { + $this->validateCsrf(); + $helper = \Helper\Dashboard::instance(); + $widgets = json_decode($f3->get("POST.widgets"), null, 512, JSON_THROW_ON_ERROR); + $allWidgets = $helper->allWidgets; + + // Validate widget list + $valid = true; + foreach ($widgets as $col) { + foreach ($col as $widget) { + if (!in_array($widget, $allWidgets)) { + $valid = false; + } + } + } + if (!$valid) { + $widgets = $helper->defaultConfig; + } + + $user = $f3->get("user_obj"); + $user->option("dashboard", $widgets); + $user->save(); + if ($f3->get("AJAX")) { + $this->_printJson($widgets); + } else { + $f3->reroute("/"); + } + } + + /** + * Get array of theme names + * @return array + */ + private function _loadThemes() + { + $themes = ["bootstrap.min"]; + foreach (glob("css/bootstrap-*.css") as $file) { + $themes[] = pathinfo($file, PATHINFO_FILENAME); + } + \Base::instance()->set("themes", $themes); + return $themes; + } + + /** + * GET /user + * + * @param \Base $f3 + */ + public function account($f3) + { + $f3->set("title", $f3->get("dict.my_account")); + $f3->set("menuitem", "user"); + $f3->set("languages", $this->_languages); + $this->_loadThemes(); + $this->_render("user/account.html"); + } + + /** + * POST /user + * + * @param \Base $f3 + * @throws \Exception + */ + public function save($f3) + { + $this->validateCsrf(); + $f3 = \Base::instance(); + $post = array_map("trim", $f3->get("POST")); + + $user = new \Model\User(); + $user->load($this->_userId); + + if (!empty($post["old_pass"])) { + $security = \Helper\Security::instance(); + + // Update password + if (hash_equals($security->hash($post["old_pass"], $user->salt), $user->password)) { + $min = $f3->get("security.min_pass_len"); + if (strlen($post["new_pass"]) >= $min) { + if ($post["new_pass"] == $post["new_pass_confirm"]) { + $user->salt = $security->salt(); + $user->password = $security->hash($post["new_pass"], $user->salt); + $f3->set("success", "Password updated successfully."); + } else { + $f3->set("error", "New passwords do not match"); + } + } else { + $f3->set("error", "New password must be at least {$min} characters."); + } + } else { + $f3->set("error", "Current password entered is not valid."); + } + } elseif (!empty($post["action"]) && $post["action"] == "options") { + // Update option values + $user->option("disable_mde", !empty($post["disable_mde"])); + $user->option("disable_due_alerts", !empty($post["disable_due_alerts"])); + $user->option("disable_self_notifications", !empty($post["disable_self_notifications"])); + } else { + // Update profile + if (!empty($post["name"])) { + $user->name = $post["name"]; + } else { + $error = "Please enter your name."; + } + if (preg_match("/^([\p{L}\.\\-\d]+)@([\p{L}\-\.\d]+)((\.(\p{L})+)+)$/im", $post["email"])) { + $user->email = $post["email"]; + } else { + $error = $post["email"] . " is not a valid email address."; + } + if (empty($error) && ctype_xdigit(ltrim($post["task_color"], "#"))) { + $user->task_color = ltrim($post["task_color"], "#"); + } elseif (empty($error)) { + $error = $post["task_color"] . " is not a valid color code."; + } + + if (empty($post["theme"])) { + $user->theme = null; + } else { + $user->theme = $post["theme"]; + } + + if (empty($post["language"])) { + $user->language = null; + } else { + $user->language = $post["language"]; + } + + if (empty($error)) { + $f3->set("success", "Profile updated successfully."); + } else { + $f3->set("error", $error); + } + } + + $user->save(); + $f3->set("title", $f3->get("dict.my_account")); + $f3->set("menuitem", "user"); + + // Use new user values for page + $user->loadCurrent(); + + $f3->set("languages", $this->_languages); + $this->_loadThemes(); + + $this->_render("user/account.html"); + } + + /** + * POST /user/avatar + * + * @param \Base $f3 + * @throws \Exception + */ + public function avatar($f3) + { + $this->validateCsrf(); + $f3 = \Base::instance(); + + $user = new \Model\User(); + $user->load($this->_userId); + if (!$user->id) { + $f3->error(404); + return; + } + + $web = \Web::instance(); + + $f3->set("UPLOADS", 'uploads/avatars/'); + if (!is_dir($f3->get("UPLOADS"))) { + mkdir($f3->get("UPLOADS"), 0777, true); + } + $overwrite = true; + $slug = true; + + // Make a good name + $parts = pathinfo($_FILES['avatar']['name']); + $_FILES['avatar']['name'] = $user->id . "-" . substr(uniqid(), 0, 4) . "." . $parts["extension"]; + $f3->set("avatar_filename", $_FILES['avatar']['name']); + + // Verify file is an image + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $allowedTypes = ['image/jpeg', 'image/gif', 'image/png', 'image/bmp']; + if (!in_array(finfo_file($finfo, $_FILES['avatar']['tmp_name']), $allowedTypes)) { + $f3->error(415); + return; + } + finfo_close($finfo); + + $web->receive( + function ($file) use ($f3, $user) { + if ($file['size'] > $f3->get("files.maxsize")) { + return false; + } + + $user->avatar_filename = $f3->get("avatar_filename"); + $user->save(); + return true; + }, + $overwrite, + $slug + ); + + // Clear cached profile picture data + $cache = \Cache::instance(); + // @1x + $cache->clear($f3->hash("GET /avatar/48/{$user->id}.png") . ".url"); + $cache->clear($f3->hash("GET /avatar/96/{$user->id}.png") . ".url"); + $cache->clear($f3->hash("GET /avatar/128/{$user->id}.png") . ".url"); + // @2x + $cache->clear($f3->hash("GET /avatar/192/{$user->id}.png") . ".url"); + $cache->clear($f3->hash("GET /avatar/256/{$user->id}.png") . ".url"); + + $f3->reroute("/user"); + } + + /** + * GET /user/@username + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single($f3, $params) + { + $this->_requireLogin(); + + $user = new \Model\User(); + $user->load(["username = ?", $params["username"]]); + + if ($user->id && (!$user->deleted_date || $f3->get("user.rank") >= 3)) { + $f3->set("title", $user->name); + $f3->set("this_user", $user); + + // Extra arrays required for bulk update + $status = new \Model\Issue\Status(); + $f3->set("statuses", $status->find(null, null, $f3->get("cache_expire.db"))); + + $f3->set("users", $user->getAll()); + $f3->set("groups", $user->getAllGroups()); + + $priority = new \Model\Issue\Priority(); + $f3->set("priorities", $priority->find(null, ["order" => "value DESC"], $f3->get("cache_expire.db"))); + + $type = new \Model\Issue\Type(); + $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); + + $issue = new \Model\Issue\Detail(); + $f3->set("created_issues", $issue->paginate( + 0, + 200, + ["status_closed = '0' AND deleted_date IS NULL AND author_id = ?", $user->id], + ["order" => "priority DESC, due_date DESC"] + )); + $f3->set("assigned_issues", $issue->paginate( + 0, + 200, + ["status_closed = '0' AND deleted_date IS NULL AND owner_id = ?", $user->id], + ["order" => "priority DESC, due_date DESC"] + )); + $f3->set("overdue_issues", $issue->paginate(0, 200, ["status_closed = '0' AND deleted_date IS NULL AND owner_id = ? AND due_date IS NOT NULL AND due_date < ?", $user->id, date("Y-m-d", \Helper\View::instance()->utc2local())], ["order" => "due_date ASC"])); + + $this->_render("user/single.html"); + } else { + $f3->error(404); + } + } + + /** + * Convert a flat issue array to a tree array. Child issues are added to + * the 'children' key in each issue. + * @param array $array Flat array of issues, including all parents needed + * @return array Tree array where each issue contains its child issues + */ + protected function _buildTree($array) + { + $tree = []; + + // Create an associative array with each key being the ID of the item + foreach ($array as $k => &$v) { + $tree[$v['id']] = &$v; + } + + // Loop over the array and add each child to their parent + foreach ($tree as $k => &$v) { + if (empty($v['parent_id'])) { + continue; + } + $tree[$v['parent_id']]['children'][] = &$v; + } + + // Loop over the array again and remove any items that don't have a parent of 0; + foreach ($tree as $k => &$v) { + if (empty($v['parent_id'])) { + continue; + } + unset($tree[$k]); + } + + return $tree; + } + + /** + * GET /user/@username/tree + * + * @param \Base $f3 + * @param array $params + * @throws \Exception + */ + public function single_tree($f3, $params) + { + $this->_requireLogin(); + + $user = new \Model\User(); + $user->load(["username = ? AND deleted_date IS NULL", $params["username"]]); + + if ($user->id) { + $f3->set("title", $user->name); + $f3->set("this_user", $user); + + $tree = \Helper\Dashboard::instance()->issue_tree(); + + $f3->set("issues", $tree); + $this->_render($f3->get("AJAX") ? "user/single/tree/ajax.html" : "user/single/tree.html"); + } else { + $f3->error(404); + } + } } diff --git a/app/dict/cs.ini b/app/dict/cs.ini index 6ad50719..28a90238 100644 --- a/app/dict/cs.ini +++ b/app/dict/cs.ini @@ -4,9 +4,9 @@ register=Registrovat username=Uživatel password=Heslo email=Email -email_address=Email address +email_address=Emailová adresa -reset_password=Reset Password +reset_password=Změnit heslo new_password=Nové heslo confirm_password=Opakujte heslo @@ -29,8 +29,8 @@ install_phproject=Instalovat Phpproject ; Navbar, basic terminology new=Nový Task=Task -Project=Project -Bug=Bug +Project=Projekt +Bug=Chyba sprint=Sprint sprints=Sprints browse=Hledat @@ -40,18 +40,18 @@ created_by_me=Vytvořeno mnou assigned_to_me=Assigned to me by_type=Podle typu issue_search=Quickly find an issue -administration=Administration +administration=Administrace dashboard=Administrace issues=Issues -my_issues=My Issues -my_account=My Account -configuration=Configuration -plugins=Plugins -users=Users -groups=Groups -log_out=Log Out +my_issues=Moje problémy +my_account=Můj účet +configuration=Konfigurace +plugins=Pluginy +users=Uživatelé +groups=Skupiny +log_out=Odhlásit se demo_notice=This site is running in demo mode. All content is public and may be reset at any time. -loading=Loading… +loading=Načítám… close=Zavřít‏ related=Related current_issue=Current issue @@ -62,7 +62,7 @@ error.loading_issue_history=Error loading issue history. error.loading_issue_watchers=Error loading issue watchers. error.loading_related_issues=Error loading related issues. error.loading_dependencies=Error loading dependencies. -error.404_text=The page you requested is not available. +error.404_text=Požadovaná stránka není k dispozici. ; Dashboard taskboard=Taskboard @@ -70,23 +70,27 @@ my_projects=Moje projekty subprojects=Subprojects my_subprojects=Moje podprojekty my_tasks=Moje úkoly -bug=bug -bugs=bugs -my_bugs=My Bugs +bug=chyba +bugs=chyby +my_bugs=Moje Chyby repeat_work=Repeat Work my_watchlist=My Watch List -projects=Projects +projects=Projekty add_project=Přidat projekt add_task=Přidat úkol add_bug=Přidat chybu -my_comments=My Comments -recent_comments=Recent Comments +my_comments=Moje komentáře +recent_comments=Poslední komentáře open_comments=Open Comments add_widgets=Add Widgets no_more_widgets=No more widgets available no_matching_issues=No matching issues parent_project=Parent Project +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets + ; User pages created_issues=Created Issues assigned_issues=Assigned Issues @@ -107,6 +111,8 @@ profile=Profil settings=Settings default=Default disable_editor=Disable Editor +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=General @@ -121,6 +127,7 @@ deactivated_users=Deactivated Users ; Issue fields cols.id=Id cols.title=Název +cols.size_estimate=Size Estimate cols.description=Popis cols.type=Typ cols.priority=Priorita @@ -136,6 +143,7 @@ cols.repeat_cycle=Opakovat cols.parent_id=Nadřazené ID cols.parent=Rodič cols.sprint=Sprint +cols.due_date_sprint=Set Due Date by Sprint cols.created=Vytvořeno cols.start=Počátek cols.due=Probíhá @@ -144,25 +152,29 @@ cols.hours_spent=Strávené hodiny cols.depends_on=Závisí na ; Issue Statuses -Active=Active -New=New +Active=Aktivní +New=Nový On Hold=On Hold -Completed=Completed +Completed=Dokončené ; Issue Priorities High=High -Normal=Normal +Normal=Normální Low=Low ; Issue editing not_assigned=Nepřiřazeno choose_option=Vyberte +set_sprint_from_due_date=Add to sprint by due date repeating=Opakování not_repeating=Neopakuje se daily=Denně weekly=Týdně monthly=Měsíčně +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=No Sprint @@ -170,34 +182,34 @@ comment=Komentář send_notifications=Poslat oznámení reset=Vymazat -new_n=New {0} +new_n=Nové {0} edit_n=Upravit {0} -create_n=Create {0} +create_n=Vytvořit {0} save_n=Uložit {0} unsaved_changes=You have unsaved changes. ; Issue page -mark_complete=Mark Complete -complete=Complete +mark_complete=Označit jako dokončené +complete=Dokončeno reopen=Znovu otevřít show_on_taskboard=Show on Taskboard -copy=Copy +copy=Kopírovat watch=Sleduj unwatch=Unwatch -edit=Edit +edit=Upravit delete=Smazat files=Soubory upload=Nahrát -upload_a_file=Upload a File +upload_a_file=Nahrát soubor attached_file=Attached file -deleted=Deleted +deleted=Odstraněno file_name=Název souboru -uploaded_by=Uploaded By +uploaded_by=Náhráno uživatelem upload_date=Upload Date -file_size=File Size -file_deleted=File deleted. +file_size=Velikost souboru +file_deleted=Soubor byl úspěšně smazán. undo=Undo comments=Komentáře @@ -210,7 +222,7 @@ related_task=Related Task related_tasks=Related Tasks notifications_sent=Odeslaná oznámení -notifications_not_sent=Notifications not sent +notifications_not_sent=Žádné oznámení nebyly odeslány a_changed={0} changed: a_changed_from_b_to_c={0} changed from {1} to {2} a_set_to_b={0} set to {1} @@ -252,6 +264,7 @@ comment_delete_confirm=Are you sure you want to delete this comment? bulk_actions=Show Bulk Actions bulk_update=Aktualizovat všechny vybrané úkoly +update_copy=Update a copy project_overview=Přehled projektu project_tree=Strom projektu @@ -261,8 +274,8 @@ n_child_issues={0} child issues ; Tags issue_tags=Issue Tags no_tags_created=No issue tags have been created yet. -tag_help_1=Tag an issue by adding a #hashtag to its description. -tag_help_2=Hashtags can contain letters, numbers, and hyphens, and must start with a letter. +tag_help_1=Označte při problému #hashtag k jeho popisu. +tag_help_2=Hashtagy mohou obsahovat písmena, číslice a spojovníky a musí začínat písmenem. view_all_tags=Zobrazit všechny Tagy list=Seznam cloud=Cloud @@ -270,6 +283,7 @@ count=Count ; Taskboard/Backlog backlog=Backlog +backlog_points=Body filter_tasks=Filter Tasks filter_projects=Filter Projects all_projects=All Projects @@ -277,8 +291,8 @@ all_tasks=All Tasks my_groups=My Groups burndown=Burndown hours_remaining=Zbývající hodiny -ideal_hours_remaining=Ideal Hours Remaining -daily_hours_remaining=Daily Hours Remaining +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining project=Projekt subproject=Subproject track_time_spent=Track Time Spent @@ -287,21 +301,24 @@ backlog_old_help_text=Drag projects here to remove from a sprint show_previous_sprints=Zobrazit předchozí sprinty show_future_sprints=Show Future Sprints unsorted_projects=Unsorted Projects +unsorted_items=Unsorted Items ; Administration -version=Version -update_available=Update Available +release_available=A new release is available! +releases=Zobrazit aktualizace +version=Verze +update_available=Aktualizace je k dispozici update_to_n=Update to {0} -backup_db=Are you sure?\nYou should back up your database before you proceed. +backup_db=Jste si jistě? \n Měli by jste si nejdřive zálohovat databázi, než budete pokračovat. ; Admin Overview Tab overview=Přehled -database=Database +database=Databáze hostname=Hostname -schema=Schema -current_version=Current Version +schema=Schéma +current_version=Aktuální verze cache=Cache -clear_cache=Clear Cache +clear_cache=Vymazat Cache timeouts=Timeouts queries=Queries minify=Minify @@ -335,9 +352,9 @@ advanced_options=Advanced Options (all enabled by default) convert_ids=Convert IDs to Links convert_hashtags=Convert Hashtags to Links convert_urls=Convert URLs to links -convert_emoticons=Convert emoticons to glyphs +convert_emoticons=Convert text emoticons to Emoji ; Email Section -email_smtp_imap=Email (SMTP/IMAP) +email_smtp_imap=E-mail (SMTP/IMAP) config_note=Note email_leave_blank=Leave blank to disable outgoing email outgoing_mail=Outgoing Mail (SMTP) @@ -349,7 +366,7 @@ imap_settings_note=IMAP settings here will have no effect unless the {0} cron is ; Advanced Section advanced=Advanced security=Security -min_pass_len=Minimum Password Length +min_pass_len=Minimální délka hesla censor_credit_card_numbers=Censor Credit Card Numbers cookie_expiration=Cookie Expiration (seconds) max_upload_size=Max Upload Size (bytes) @@ -369,16 +386,16 @@ show_deactivated_users=Show Deactivated Users ; User Modal edit_user=Edit User require_new_password=Require a new password on next login -user=User -administrator=Administrator -rank=Rank -ranks.0=Guest -ranks.1=Client -ranks.2=User -ranks.3=Manager +user=Uživatel +administrator=Administrátor +rank=Hodnost +ranks.0=Návštěvník +ranks.1=Klient +ranks.2=Uživatel +ranks.3=Vedoucí ranks.4=Admin ranks.5=Super Admin -rank_permissions.0=Read-only +rank_permissions.0=Pouze pro čtení rank_permissions.1=Can post comments rank_permissions.2=Can create/edit issues rank_permissions.3=Can delete issues/comments @@ -386,14 +403,14 @@ rank_permissions.4=Can create/edit/delete users/groups/sprints rank_permissions.5=Can edit configuration ; Admin Groups Tab -no_groups_exist=No groups exist. +no_groups_exist=Žádné skupiny neexistují. ; Groups Modal -members=Members -group_name=Group Name -group_name_saved=Group name saved -manager=Manager +members=Členové +group_name=Název skupiny +group_name_saved=Název skupiny uložen +manager=Vedoucí set_as_manager=Set as Manager -add_to_group=Add to Group +add_to_group=Přidat do skupiny api_visible=Visible to API ; Admin Sprints Tab diff --git a/app/dict/de.ini b/app/dict/de.ini index 37072641..e491409f 100644 --- a/app/dict/de.ini +++ b/app/dict/de.ini @@ -27,9 +27,9 @@ install_phproject=Installiere Phproject ; Navbar, basic terminology new=Neu -Task=Task -Project=Project -Bug=Bug +Task=Aufgabe +Project=Projekt +Bug=Fehler sprint=Sprint sprints=Sprints browse=Durchsuchen @@ -66,25 +66,29 @@ error.404_text=Die angeforderte Seite ist nicht verfügbar. ; Dashboard taskboard=Aufgabenübersicht my_projects=Meine Projekte -subprojects=Subprojects +subprojects=Unterprojekt my_subprojects=Meine Unterprojekte my_tasks=Meine Aufgaben -bug=bug -bugs=bugs +bug=Fehler +bugs=Fehler my_bugs=Meine Bugs repeat_work=Wiederholende Aufgaben my_watchlist=Meine Beobachtungsliste -projects=Projects +projects=Projekte add_project=Projekt hinzufügen add_task=Aufgabe hinzufügen add_bug=Bug hinzufügen my_comments=Meine Kommentare recent_comments=Neueste Kommentare open_comments=Offene Kommentare -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +add_widgets=Widgets hinzufügen +no_more_widgets=Keine weiteren Widgets verfügbar +no_matching_issues=Keine passenden Aufgaben +parent_project=Übergeordnetes Projekt + +manage_widgets=Widgets verwalten +available_widgets=Verfügbare Widgets +enabled_widgets=Aktivierte Widgets ; User pages created_issues=Erstellte Tickets @@ -105,6 +109,8 @@ profile=Profil settings=Einstellungen default=Standard disable_editor=Editor deaktivieren +disable_due_alerts=Ticket Fälligkeitsemails deaktivieren +disable_self_notifications=Benachrichtigungen für eigene Aktionen deaktivieren ; Browse general=Allgemein @@ -119,6 +125,7 @@ deactivated_users=Deaktivierte Benutzer ; Issue fields cols.id=ID cols.title=Titel +cols.size_estimate=Größenschätzung cols.description=Beschreibung cols.type=Typ cols.priority=Priorität @@ -134,6 +141,7 @@ cols.repeat_cycle=Wiederholungszyklus cols.parent_id=Eltern-ID cols.parent=Übergeordnet cols.sprint=Sprint +cols.due_date_sprint=Fälligkeitsdatum nach Sprint festlegen cols.created=Erstellt cols.start=Beginn cols.due=Fällig @@ -142,25 +150,29 @@ cols.hours_spent=Stunden gebraucht cols.depends_on=Abhängig von ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Aktiv +New=Neu +On Hold=Pausiert +Completed=Erledigt ; Issue Priorities -High=High +High=Hoch Normal=Normal -Low=Low +Low=Niedrig ; Issue editing not_assigned=Nicht zugewiesen choose_option=Wähle eine Option +set_sprint_from_due_date=Zu Sprint nach Fälligkeitsdatum hinzufügen repeating=Wiederholend not_repeating=Nicht Wiederholend daily=Täglich weekly=Wöchentlich monthly=Monatlich +quarterly=Vierteljährliche +semi_annually=Halbjährlich +annually=Jährlich no_sprint=Kein Sprint @@ -173,7 +185,7 @@ edit_n=Bearbeite {0} create_n=Erstelle {0} save_n=Speichere {0} -unsaved_changes=Es gibt ungesicherte Änderungen. +unsaved_changes=Es gibt nicht gespeicherte Änderungen. ; Issue page mark_complete=Als erledigt markieren @@ -201,10 +213,10 @@ undo=Rückgängig comments=Kommentare history=Verlauf watchers=Beobachter -child_task=Child Task +child_task=Unteraufgabe child_tasks=Unter-Aufgaben sibling_tasks=Geschwister-Aufgaben -related_task=Related Task +related_task=Verwandte Aufgabe related_tasks=Verwandte Aufgaben notifications_sent=Benachrichtigungen gesendet @@ -250,16 +262,18 @@ comment_delete_confirm=Bist du sicher, dass du diesen Kommentar löschen möchte bulk_actions=Aktion für Ausgewählte bulk_update=Aktualisiere alle ausgewählten Aufgaben +update_copy=Aktualisieren einer Kopie project_overview=Projekt Übersicht project_tree=Projekt Baum n_complete={0} erledigt n_child_issues={0} Unter-Tickets +project_no_files_attached=Es sind keine Dateien an Probleme in diesem Projekt angehangen. ; Tags issue_tags=Ticket Tags no_tags_created=Es wurden bisher keine Ticket Tags erstellt. -tag_help_1=Tagge ein Ticket indem du einen #hashtag in seiner Beschreibung hinzufügst. +tag_help_1="Tagge ein Ticket indem du einen #hashtag in seiner Beschreibung hinzufügst." tag_help_2=Hashtags dürfen Buchstaben, Nummern und Unterstriche enthalten und müssen mit einem Buchstaben beginnen. view_all_tags=Zeige alle Tags list=Liste @@ -268,6 +282,7 @@ count=Zähler ; Taskboard/Backlog backlog=Backlog +backlog_points=Punkte filter_tasks=Aufgaben filtern filter_projects=Projekte filtern all_projects=Alle Projekte @@ -275,8 +290,8 @@ all_tasks=Alle Aufgaben my_groups=Meine Gruppen burndown=Burndown hours_remaining=Stunden übrig -ideal_hours_remaining=Ideale Stunden übrig -daily_hours_remaining=Tägliche Stunden übrig +man_hours=Arbeitsstunden +man_hours_remaining=Verbleibende Arbeitsstunden project=Projekt subproject=Unterprojekt track_time_spent=Erfasse benötigte Zeit @@ -284,9 +299,12 @@ burn_hours=Stunden verbrennen backlog_old_help_text=Projekt hierher ziehen um es aus dem Sprint zu entfernen show_previous_sprints=Zeige vorherige Sprints show_future_sprints=Zeige zukünftige Sprints -unsorted_projects=Unsortiert Projekte +unsorted_projects=Unsortierte Projekte +unsorted_items=Unsortierte Elemente ; Administration +release_available=Eine neue Version ist verfügbar! +releases=Releases anzeigen version=Version update_available=Update verfügbar update_to_n=Update zu {0} @@ -294,21 +312,23 @@ backup_db=Sind Sie sicher?\nSie sollten ein Backup Ihrer Datenbank machen, bevor ; Admin Overview Tab overview=Übersicht -database=Database +database=Datenbank hostname=Hostname +mailbox=E-Mail-Postfach schema=Schema current_version=Aktuelle Version -cache=Cache -clear_cache=Clear Cache +cache=Zwischenspeicher +clear_cache=Zwischenspeicher leeren timeouts=Timeouts -queries=Queries +queries=Abfragen minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime -max_rating=Max Rating +attachments=Anhänge +smtp_not_enabled=SMTP ist nicht aktiviert. +imap_not_enabled=IMAP ist nicht aktiviert. +miscellaneous=Verschiedenes +session_lifetime=Sitzungsdauer +max_rating=Max Bewertung +mailbox_help=Nicht sicher, was verwendet werden soll? Siehe die Dokumentation. ; Admin Configuration Tab ; Site Basics Section @@ -322,8 +342,8 @@ logo=Logo issue_types_and_statuses=Ticket Typen und Status issue_types=Ticket Typen issue_statuses=Ticket Status -yes=Yes -no=No +yes=Ja +no=Nein taskboard_columns=Taskboard Spalten taskboard_sort=Taskboard sortieren ; Text Parsing Section @@ -333,7 +353,7 @@ advanced_options=Erweiterte Optionen (alle standardmäßig aktiviert) convert_ids=IDs in Links umwandeln convert_hashtags=Hashtags in Links umwandeln convert_urls=URLs in Links umwandeln -convert_emoticons=Emoticons umwandeln +convert_emoticons=Text-Emoticons in Emoji umwandeln ; Email Section email_smtp_imap=E-Mail (SMTP/IMAP) config_note=Anmerkung @@ -357,6 +377,8 @@ debug_level=Debug-Level (DEBUG) cache_mode=Cachemodus (CACHE) demo_user=Demo-Benutzer (site.demo) advanced_config_note=Diese Werte können geändert werden, indem Sie die Werte in der {0} Datenbank-Tabelle editieren. +restrict_access=Zugriff einschränken +restrict_access_detail=Den Zugang zu Problemen, Benutzern und Gruppen nach Problemeigentümern und Gruppenmitgliedern beschränken. ; Admin Users Tab new_user=Neuer Benutzer @@ -392,7 +414,7 @@ group_name_saved=Gruppenname gespeichert manager=Manager set_as_manager=Zum Manager ernennen add_to_group=Zur Gruppe hinzufügen -api_visible=Visible to API +api_visible=Sichtbar für API ; Admin Sprints Tab ; Sprint Modal @@ -401,3 +423,11 @@ edit_sprint=Sprint bearbeiten start_date=Startdatum end_date=Enddatum +; Hotkey modal +hotkeys=Hotkeys +hotkeys_global=Generelle +show_hotkeys=Hotkeys anzeigen +reload_page=Seite neu laden +press_x_then_y={0} dann {1} +navigation=Navigation +use_with_x=Mit {0} verwenden diff --git a/app/dict/en-GB.ini b/app/dict/en-GB.ini index 7ea22621..3f428bc5 100644 --- a/app/dict/en-GB.ini +++ b/app/dict/en-GB.ini @@ -27,6 +27,9 @@ install_phproject=Install Phproject ; Navbar, basic terminology new=New +Task=Task +Project=Project +Bug=Bug sprint=Sprint sprints=Sprints browse=Browse @@ -63,17 +66,25 @@ error.404_text=The page you requested is not available. ; Dashboard taskboard=Taskboard my_projects=My Projects +subprojects=Subprojects my_subprojects=My Subprojects my_tasks=My Tasks +bug=bug +bugs=bugs my_bugs=My Bugs repeat_work=Repeat Work my_watchlist=My Watch List +projects=Projects add_project=Add Project add_task=Add Task add_bug=Add Bug my_comments=My Comments recent_comments=Recent Comments open_comments=Open Comments +add_widgets=Add Widgets +no_more_widgets=No more widgets available +no_matching_issues=No matching issues +parent_project=Parent Project ; User pages created_issues=Created Issues @@ -102,6 +113,8 @@ submit=Submit export=Export go_previous=Previous go_next=Next +previous_sprints=Previous Sprints +deactivated_users=Deactivated Users ; Issue fields cols.id=ID @@ -128,6 +141,17 @@ cols.closed_date=Closed cols.hours_spent=Hours Spent cols.depends_on=Depends On +; Issue Statuses +Active=Active +New=New +On Hold=On Hold +Completed=Completed + +; Issue Priorities +High=High +Normal=Normal +Low=Low + ; Issue editing not_assigned=Not Assigned choose_option=Choose an option @@ -149,6 +173,8 @@ edit_n=Edit {0} create_n=Create {0} save_n=Save {0} +unsaved_changes=You have unsaved changes. + ; Issue page mark_complete=Mark Complete complete=Complete @@ -175,8 +201,10 @@ undo=Undo comments=Comments history=History watchers=Watchers +child_task=Child Task child_tasks=Child Tasks sibling_tasks=Sibling Tasks +related_task=Related Task related_tasks=Related Tasks notifications_sent=Notifications sent @@ -256,84 +284,120 @@ burn_hours=Burn hours backlog_old_help_text=Drag projects here to remove from a sprint show_previous_sprints=Show Previous Sprints show_future_sprints=Show Future Sprints +unsorted_projects=Unsorted Projects ; Administration -overview=Overview -new_user=New User -edit_user=Edit User -require_new_password=Require a new password on next login -role=Role -user=User -deactivate=Deactivate -reactivate=Reactivate -administrator=Administrator -members=Members -group_name=Group Name -group_name_saved=Group name saved -manager=Manager -set_as_manager=Set as Manager -add_to_group=Add to Group -no_groups_exist=No groups exist. -new_sprint=New Sprint -edit_sprint=Edit Sprint -start_date=Start Date -end_date=End Date version=Version -current_version=Current Version update_available=Update Available update_to_n=Update to {0} backup_db=Are you sure?\nYou should back up your database before you proceed. -show_deactivated_users=Show Deactivated Users -; User Ranks -rank=Rank -ranks.0=Guest -ranks.1=Client -ranks.2=User -ranks.3=Manager -ranks.4=Admin -ranks.5=Super Admin -rank_permissions.0=Read-only -rank_permissions.1=Can post comments -rank_permissions.2=Can create/edit issues -rank_permissions.3=Can delete issues/comments -rank_permissions.4=Can create/edit/delete users/groups/sprints -rank_permissions.5=Can edit configuration - -; Config +; Admin Overview Tab +overview=Overview +database=Database +hostname=Hostname +schema=Schema +current_version=Current Version +cache=Cache +clear_cache=Clear Cache +timeouts=Timeouts +queries=Queries +minify=Minify +attachments=Attachments +smtp_not_enabled=SMTP is not enabled. +imap_not_enabled=IMAP is not enabled. +miscellaneous=Miscellaneous +session_lifetime=Session Lifetime +max_rating=Max Rating + +; Admin Configuration Tab +; Site Basics Section site_basics=Site Basics -text_parsing=Text Parsing -email_smtp_imap=Email (SMTP/IMAP) -advanced=Advanced site_name=Site Name site_description=Site Description timezone=Timezone default_theme=Default Theme logo=Logo -allow_public_registration=Allow public registration +; Issue Types and Statuses Section +issue_types_and_statuses=Issue Types and Statuses +issue_types=Issue Types +issue_statuses=Issue Statuses +yes=Yes +no=No +taskboard_columns=Taskboard Columns +taskboard_sort=Taskboard Sort +; Text Parsing Section +text_parsing=Text Parsing parser_syntax=Parser Syntax advanced_options=Advanced Options (all enabled by default) convert_ids=Convert IDs to Links convert_hashtags=Convert Hashtags to Links convert_urls=Convert URLs to links convert_emoticons=Convert emoticons to glyphs +; Email Section +email_smtp_imap=Email (SMTP/IMAP) +config_note=Note +email_leave_blank=Leave blank to disable outgoing email outgoing_mail=Outgoing Mail (SMTP) from_address=From Address +package_mail_config_note={0} uses your default PHP mail configuration for outgoing email. incoming_mail=Incoming Mail (IMAP) -hostname=Hostname -debug_level=Debug Level (DEBUG) -cache_mode=Cache Mode (CACHE) -cookie_expiration=Cookie Expiration (seconds) -max_upload_size=Max Upload Size (bytes) -censor_credit_card_numbers=Censor Credit Card Numbers -demo_user=Demo User (site.demo) -config_note=Note imap_truncate_lines=IMAP Message Truncate Lines -package_mail_config_note={0} uses your default PHP mail configuration for outgoing email. imap_settings_note=IMAP settings here will have no effect unless the {0} cron is being run. -email_leave_blank=Leave blank to disable outgoing email -advanced_config_note=These values can be changed by editing the values in the {0} database table. -min_pass_len=Minimum Password Length +; Advanced Section +advanced=Advanced security=Security +min_pass_len=Minimum Password Length +censor_credit_card_numbers=Censor Credit Card Numbers +cookie_expiration=Cookie Expiration (seconds) +max_upload_size=Max Upload Size (bytes) +allow_public_registration=Allow public registration core=Core +debug_level=Debug Level (DEBUG) +cache_mode=Cache Mode (CACHE) +demo_user=Demo User (site.demo) +advanced_config_note=These values can be changed by editing the values in the {0} database table. + +; Admin Users Tab +new_user=New User +deactivate=Deactivate +reactivate=Reactivate +role=Role +show_deactivated_users=Show Deactivated Users +; User Modal +edit_user=Edit User +require_new_password=Require a new password on next login +user=User +administrator=Administrator +rank=Rank +ranks.0=Guest +ranks.1=Client +ranks.2=User +ranks.3=Manager +ranks.4=Admin +ranks.5=Super Admin +rank_permissions.0=Read-only +rank_permissions.1=Can post comments +rank_permissions.2=Can create/edit issues +rank_permissions.3=Can delete issues/comments +rank_permissions.4=Can create/edit/delete users/groups/sprints +rank_permissions.5=Can edit configuration + +; Admin Groups Tab +no_groups_exist=No groups exist. +; Groups Modal +members=Members +group_name=Group Name +group_name_saved=Group name saved +manager=Manager +set_as_manager=Set as Manager +add_to_group=Add to Group +api_visible=Visible to API + +; Admin Sprints Tab +; Sprint Modal +new_sprint=New Sprint +edit_sprint=Edit Sprint +start_date=Start Date +end_date=End Date diff --git a/app/dict/en.ini b/app/dict/en.ini index b1c6e174..1f3acf47 100644 --- a/app/dict/en.ini +++ b/app/dict/en.ini @@ -86,6 +86,11 @@ no_more_widgets=No more widgets available no_matching_issues= No matching issues parent_project=Parent Project +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets +no_more_widgets=No more widgets available + ; User pages created_issues=Created Issues assigned_issues=Assigned Issues @@ -105,6 +110,8 @@ profile=Profile settings=Settings default=Default disable_editor=Disable Editor +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=General @@ -119,6 +126,7 @@ deactivated_users=Deactivated Users ; Issue fields cols.id=ID cols.title=Title +cols.size_estimate=Size Estimate cols.description=Description cols.type=Type cols.priority=Priority @@ -134,6 +142,7 @@ cols.repeat_cycle=Repeat Cycle cols.parent_id=Parent ID cols.parent=Parent cols.sprint=Sprint +cols.due_date_sprint=Set Due Date by Sprint cols.created=Created cols.start=Start cols.due=Due @@ -155,12 +164,16 @@ Low=Low ; Issue editing not_assigned=Not Assigned choose_option=Choose an option +set_sprint_from_due_date=Add to sprint by due date repeating=Repeating not_repeating=Not Repeating daily=Daily weekly=Weekly monthly=Monthly +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=No Sprint @@ -249,13 +262,14 @@ restore_issue=Restore Issue comment_delete_confirm=Are you sure you want to delete this comment? bulk_actions=Show Bulk Actions -bulk_update=Bulk Update +bulk_update=Update selected items update_copy=Update a copy project_overview=Project Overview project_tree=Project Tree n_complete={0} complete n_child_issues={0} child issues +project_no_files_attached=No files are attached to issues in this project. ; Tags issue_tags=Issue Tags @@ -269,6 +283,7 @@ count=Count ; Taskboard/Backlog backlog=Backlog +backlog_points=Points filter_tasks=Filter Tasks filter_projects=Filter Projects all_projects=All Projects @@ -276,8 +291,8 @@ all_tasks=All Tasks my_groups=My Groups burndown=Burndown hours_remaining=Hours Remaining -ideal_hours_remaining=Ideal Hours Remaining -daily_hours_remaining=Daily Hours Remaining +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining project=Project subproject=Subproject track_time_spent=Track Time Spent @@ -286,8 +301,11 @@ backlog_old_help_text=Drag projects here to remove from a sprint show_previous_sprints=Show Previous Sprints show_future_sprints=Show Future Sprints unsorted_projects=Unsorted Projects +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Version update_available=Update Available update_to_n=Update to {0} @@ -297,6 +315,7 @@ backup_db=Are you sure?\nYou should back up your database before you proceed. overview=Overview database=Database hostname=Hostname +mailbox=Mailbox schema=Schema current_version=Current Version cache=Cache @@ -310,6 +329,7 @@ imap_not_enabled=IMAP is not enabled. miscellaneous=Miscellaneous session_lifetime=Session Lifetime max_rating=Max Rating +mailbox_help=Not sure what to use? See the documentation. ; Admin Configuration Tab ; Site Basics Section @@ -334,7 +354,7 @@ advanced_options=Advanced Options (all enabled by default) convert_ids=Convert IDs to Links convert_hashtags=Convert Hashtags to Links convert_urls=Convert URLs to links -convert_emoticons=Convert emoticons to glyphs +convert_emoticons=Convert text emoticons to Emoji ; Email Section email_smtp_imap=Email (SMTP/IMAP) config_note=Note @@ -359,6 +379,8 @@ debug_level=Debug Level (DEBUG) cache_mode=Cache Mode (CACHE) demo_user=Demo User (site.demo) advanced_config_note=These values can be changed by editing the values in the {0} database table. +restrict_access=Restrict access +restrict_access_detail=Restrict access to issues, users, and groups by issue owner and group members. ; Admin Users Tab new_user=New User @@ -402,3 +424,12 @@ new_sprint=New Sprint edit_sprint=Edit Sprint start_date=Start Date end_date=End Date + +; Hotkey modal +hotkeys=Hotkeys +hotkeys_global=Global +show_hotkeys=Show hotkeys +reload_page=Reload page +press_x_then_y={0} then {1} +navigation=Navigation +use_with_x=Use with {0} diff --git a/app/dict/es.ini b/app/dict/es.ini index f3597c28..15361f65 100644 --- a/app/dict/es.ini +++ b/app/dict/es.ini @@ -27,9 +27,9 @@ install_phproject=Instale Phproject ; Navbar, basic terminology new=Nueva -Task=Task -Project=Project -Bug=Bug +Task=Tarea +Project=Proyecto +Bug=Error sprint=Sprint sprints=Sprints browse=Navegar @@ -66,25 +66,29 @@ error.404_text=La página solicitada no está disponible. ; Dashboard taskboard=Tablero de tareas my_projects=Mis Proyectos -subprojects=Subprojects +subprojects=Subproyectos my_subprojects=Mis Subproyectos my_tasks=Mis Tareas -bug=bug -bugs=bugs +bug=error +bugs=errores my_bugs=Mis Errores repeat_work=Trabajo de la repetición my_watchlist=Mi lista de observar -projects=Projects +projects=Proyectos add_project=Crear Proyecto add_task=Crear Tarea add_bug=Crear Error my_comments=Mis Comentarios -recent_comments=Comentarios Recientes +recent_comments=Comentarios recientes open_comments=Comentarios abiertos -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +add_widgets=Añadir Widgets +no_more_widgets=No más widgets disponibles +no_matching_issues=Sin peticiónes de conciliación +parent_project=Proyecto padre + +manage_widgets=Administrar widgets +available_widgets=Widgets disponibles +enabled_widgets=Widgets habilitados ; User pages created_issues=Peticiónes creados @@ -104,7 +108,9 @@ current_password=Contraseña actual profile=Perfil settings=Opciones default=Defecto -disable_editor=Desactivar Editor +disable_editor=Desactivar el editor +disable_due_alerts=Desactivar emails de peticiónes vencidos +disable_self_notifications=Desactivar las notificaciones para sus propias acciones ; Browse general=General @@ -119,6 +125,7 @@ deactivated_users=Usuarios desactivados ; Issue fields cols.id=ID cols.title=Título +cols.size_estimate=Estimación del puntos cols.description=Descripción cols.type=Tipo cols.priority=Prioridad @@ -134,6 +141,7 @@ cols.repeat_cycle=Repetición cols.parent_id=ID de Padre cols.parent=Padre cols.sprint=Sprint +cols.due_date_sprint=Establecer fecha de vencimiento por sprint cols.created=Creado cols.start=Inicio cols.due=Esperado @@ -142,25 +150,29 @@ cols.hours_spent=Horas pasadas cols.depends_on=Depende de ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Activo +New=Nueva +On Hold=En espera +Completed=Completado ; Issue Priorities -High=High -Normal=Normal -Low=Low +High=Alto +Normal=Medio +Low=Bajo ; Issue editing not_assigned=No asignado choose_option=Elija una opción +set_sprint_from_due_date=Añadir al sprint por fecha de vencimiento repeating=Repitiendo not_repeating=No repetir daily=Diario weekly=Semanal monthly=Mensual +quarterly=Trimestral +semi_annually=Semestral +annually=Anualmente no_sprint=No de sprint @@ -173,7 +185,7 @@ edit_n=Editar {0} create_n=Crear {0} save_n=Guardar {0} -unsaved_changes=Tienes cambios sin guardar. +unsaved_changes=Tiene cambios sin guardar. ; Issue page mark_complete=Marca Completa @@ -201,10 +213,10 @@ undo=Deshacer comments=Comentarios history=Historia watchers=Observadores -child_task=Child Task +child_task=Tarea hija child_tasks=Tareas Secundarias sibling_tasks=Tareas Hermanos -related_task=Related Task +related_task=Tarea relacionada related_tasks=Tareas Relacionadas notifications_sent=Las notificaciones enviadas @@ -242,7 +254,7 @@ no_related_issues=Peticiónes no relacionadas copy_issue=Petición copia copy_issue_details=Copia de este petición será duplicarlo y todos sus descendientes. Se copiarán No hay comentarios, archivos, historia, o los observadores. -deleted_success=Petición #{0} eliminado correctamente +deleted_success="Petición #{0} eliminado correctamente" deleted_notice=Este petición ha sido eliminado. Edición seguirá enviar notificaciones, pero los receptores no será capaz de ver el petición a menos que sean administradores. restore_issue=Restaurar esta petición @@ -250,11 +262,13 @@ comment_delete_confirm=¿Seguro que quieres borrar este comentario? bulk_actions=Mostrar acciones a granel bulk_update=Actualizar todas +update_copy=Actualizar una copia project_overview=Visión general del proyecto project_tree=Árbol de proyecto n_complete={0} completa n_child_issues={0} descendientes +project_no_files_attached=No hay archivos adjuntos a peticiónes en este proyecto. ; Tags issue_tags=Etiquetas @@ -268,6 +282,7 @@ count=Número ; Taskboard/Backlog backlog=Atrasos +backlog_points=Puntos filter_tasks=Filtrar tareas filter_projects=Filtrar proyectos all_projects=Todos proyectos @@ -275,8 +290,8 @@ all_tasks=Todos tareas my_groups=Mis grupos burndown=Quemar hours_remaining=Horas Restantes -ideal_hours_remaining=Horas Ideal Restantes -daily_hours_remaining=Horas Diarias Restantes +man_hours=Horas del hombre +man_hours_remaining=Horas de hombre restantes project=Proyecto subproject=Subproyecto track_time_spent=Registrar el tiempo empleado @@ -284,9 +299,12 @@ burn_hours=Quemar horas backlog_old_help_text=Puede arrastrar proyectos aquí para eliminarlos de un sprint show_previous_sprints=Sprints anteriores show_future_sprints=Sprints futuros -unsorted_projects=Unsorted Projects +unsorted_projects=Proyectos no clasificados +unsorted_items=Elementos no ordenados ; Administration +release_available=¡Hay una nueva versión disponible! +releases=Ver Publicaciones version=Versión update_available=Actualización disponible update_to_n=Actualización a {0} @@ -294,21 +312,23 @@ backup_db=¿Seguro que deseas actualizar?\nUsted debe respaldar su base de datos ; Admin Overview Tab overview=Sumario -database=Database +database=Base de datos hostname=Hostname -schema=Schema +mailbox=Buzón +schema=Esquema current_version=La versión actual -cache=Cache -clear_cache=Clear Cache -timeouts=Timeouts -queries=Queries -minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime -max_rating=Max Rating +cache=Antememoria +clear_cache=Borrar la antememoria +timeouts=Tiempos de espera +queries=Consultas +minify=Minificar +attachments=Anexos +smtp_not_enabled=SMTP no está activado. +imap_not_enabled=No se ha activado IMAP. +miscellaneous=Miscelánea +session_lifetime=Duración de la sesión +max_rating=Clasificación máxima +mailbox_help=¿No está seguro de qué usar? Consulte la documentación. ; Admin Configuration Tab ; Site Basics Section @@ -322,10 +342,10 @@ logo=Logo issue_types_and_statuses=Tipos y estados de peticiónes issue_types=Tipos de peticiónes issue_statuses=Estados -yes=Yes +yes=Sí no=No -taskboard_columns=Taskboard Columns -taskboard_sort=Taskboard Sort +taskboard_columns=Columnas del tablero de tareas +taskboard_sort=Clasificación del tablero de tareas ; Text Parsing Section text_parsing=Análisis de texto parser_syntax=Sintaxis De Analizador @@ -333,7 +353,7 @@ advanced_options=Opciones Avanzadas (todos activado por defecto) convert_ids=Convertir IDs en Enlaces convert_hashtags=Convertir Hashtags en Enlaces convert_urls=Convertir URLs en Enlaces -convert_emoticons=Convertir emoticonos en glifos +convert_emoticons=Convertir emoticonos de texto a Emoji ; Email Section email_smtp_imap=Correo electrónico (SMTP/IMAP) config_note=Nota @@ -392,7 +412,7 @@ group_name_saved=Nombre del grupo se salvó manager=Director set_as_manager=Establecer como director add_to_group=Añadir al grupo -api_visible=Visible to API +api_visible=Visible a API ; Admin Sprints Tab ; Sprint Modal @@ -401,3 +421,12 @@ edit_sprint=Editar sprint start_date=Fecha de inicio end_date=Fecha de finalización +; Hotkey modal +hotkeys=Tecla rápidas +hotkeys_global=Global +show_hotkeys=Mostrar teclas rápidas +reload_page=Recargar página +press_x_then_y={0} y {1} +navigation=Navegación +use_with_x=Usar con {0} + diff --git a/app/dict/et.ini b/app/dict/et.ini new file mode 100644 index 00000000..c98802fa --- /dev/null +++ b/app/dict/et.ini @@ -0,0 +1,433 @@ +; Index, login, password reset +log_in=Sisene +register=Registreeru +username=Kasutajatunnus +password=Parool +email=E-post +email_address=E-posti aadres + +reset_password=Lähtesta parool +new_password=Uus parool +confirm_password=Kinnita parool + +cancel=Loobu + +logged_out=Välja logitud +session_ended_message=Kasutussessioon on läbi. Palun autoriseeri end uuesti. + +; Footer +n_queries={0,number,integer} queries +n_total_queries_n_cached={0,number,integer} päringut kokku, {1,number,integer} puhverdatud +page_generated_in_n_seconds=Lehe loomiseks kulus {0,number} sekundit +real_usage_n_bytes=Real usage: {0,number,integer} baiti +current_commit_n=Praegune commit: {0} + +; Installer +install_phproject=Paigalda Phproject + +; Navbar, basic terminology +new=Uus +Task=Ülesanne +Project=Projekt +Bug=Viga +sprint=Tsükkel +sprints=Tsüklid +browse=Lehitse +open=Pooleliolevad +closed=Lõpetatud +created_by_me=Minu algatatud +assigned_to_me=Minule täitmiseks +by_type=Tüübi järgi +issue_search=Leia eesmärk +administration=Haldus +dashboard=Töölaud +issues=Eesmärgid +my_issues=Minu eesmärgid +my_account=Minu konto +configuration=Seadistus +plugins=Laiendused +users=Kasutajad +groups=Grupid +log_out=Välju +demo_notice=Ettevaatus! Süsteem on demo režiimis. Kõik andmed on avalikud ja võidakse iga hetk lähtestada. +loading=Laen.. +close=Sulge +related=Seotud +current_issue=Praegune eesmärk +toggle_navigation=Näita/Peida menüüd + +; Errors +error.loading_issue_history=Eesmärkide loendi laadimisel tekkis tõrge. +error.loading_issue_watchers=Jälgijate loendi laadimisel tekkis tõrge. +error.loading_related_issues=Soetud eesmärkide loendi laadimisel tekkis tõrge. +error.loading_dependencies=Seonduvate elementide loendi laadimisel tekkis tõrge. +error.404_text=Päringule vastavat lehekülge ei leitud. + +; Dashboard +taskboard=Ülesannetetahvel +my_projects=Projektid +subprojects=Alamprojektid +my_subprojects=Alamprojektid +my_tasks=Ülesanded +bug=viga +bugs=viga +my_bugs=Veateated +repeat_work=Regulaarsed +my_watchlist=Jälgimised +projects=Projektid +add_project=Lisa projekt +add_task=Lisa ülesanne +add_bug=Lisa veateade +my_comments=Minu kommentaarid +recent_comments=Uued kommentaarid +open_comments=Avatud kommentaarid +add_widgets=Lisa moodul +no_more_widgets=Rohkem mooduleid pole +no_matching_issues=Kirjeid pole +parent_project=Ülemprojekt + +manage_widgets=Halda mooduleid +available_widgets=Kasutamata mooduoid +enabled_widgets=Kasutuses moodulid + +; User pages +created_issues=Minu loodud +assigned_issues=Mulle seatud +overdue_issues=Üle tähtaja +issue_tree=Eesmärgipuu + +; Account +name=Nimi +theme=Kujundusmall +language=Keel +task_color=Minu ülesande värv +avatar=Tunnuspilt +edit_on_gravatar=Muuda Gravataris +save=Salvesta +current_password=Praegune parool +profile=Profiil +settings=Seaded +default=Vaikimisi +disable_editor=Lülita redaktor välja +disable_due_alerts=Ära saada teateid tähtaja ületanud eesmärkide kohta +disable_self_notifications=Lülita minu tehtud muudatustest teavitamine välja. + +; Browse +general=Üldine +exact_match=Täpne vaste +submit=Salvesta +export=Ekspordi +go_previous=Eelmine +go_next=Järgmine +previous_sprints=Eelmised tsüklid +deactivated_users=Deaktiveeritud kasutajad + +; Issue fields +cols.id=ID +cols.title=Pealkiri +cols.size_estimate=Skoop +cols.description=Kirjeldus +cols.type=Liik +cols.priority=Tähtsus +cols.status=Staatus +cols.author=Autor +cols.assignee=Täitja +cols.total_spent_hours=Tegelik ajakulu +cols.hours_total=Hinnanguline ajakulu +cols.hours_remaining=Aega jäänud +cols.start_date=Alguspäev +cols.due_date=Tähtaeg +cols.repeat_cycle=Regulaarsus +cols.parent_id=Vanema ID +cols.parent=Vanem +cols.sprint=Tsükkel +cols.due_date_sprint=Kasuta tsükli tähtaega +cols.created=Loomispäev +cols.start=Algus +cols.due=Tähtaeg +cols.closed_date=Lõpetatud +cols.hours_spent=Ajakulu +cols.depends_on=Sõltuvuses + +; Issue Statuses +Active=Töös +New=Uus +On Hold=Peatatud +Completed=Lõpetatud + +; Issue Priorities +High=Kõrge +Normal=Tavaline +Low=Teisejärguline + +; Issue editing +not_assigned=Määratlemata +choose_option=Tehke valik +set_sprint_from_due_date=Kasuta tsükli tähtaega + +repeating=Regulaarsus +not_repeating=Ühekordne +daily=Iga päev +weekly=Iga nädal +monthly=Iga kuu +quarterly=Iga kvartal +semi_annually=Iga poole aasta tagant +annually=Iga aasta + +no_sprint=Tsükleid pole + +comment=Kommentaar + +send_notifications=Saada teavitused +reset=Lähtesta +new_n=Uus {0} +edit_n=Muuda {0} +create_n=Loo {0} +save_n=Salvesta {0} + +unsaved_changes=Osa muudatusi on salvestamata. + +; Issue page +mark_complete=Märgi täidetuks +complete=Täidetud +reopen=Ava uuesti +show_on_taskboard=Näita ülesannetetahvlil +copy=Kopeeri +watch=Jälgi +unwatch=Lõpeta jälgimine +edit=Muuda +delete=Kustuta + +files=Failid +upload=Lae üles +upload_a_file=Lae fail üles +attached_file=Manus +deleted=Kustutatud +file_name=Nimejärgi +uploaded_by=Lisaja +upload_date=Üleslaadimise kpv +file_size=Faili suurus +file_deleted=Fail on kustutatud. +undo=Võta tagasi + +comments=Kommentaarid +history=Ajalugu +watchers=Jälgijad +child_task=Alamülesanne +child_tasks=Alamülesanded +sibling_tasks=Sõsarülesanded +related_task=Seotud ülesanne +related_tasks=Seotud ülesanded + +notifications_sent=Teavitus saadetud +notifications_not_sent=Teavitust ei saadetud +a_changed={0} muudetud: +a_changed_from_b_to_c={0} muudetud, {1} asemel {2} +a_set_to_b={0} seatud {1} +a_removed={0} eemaldatud + +dependencies=Sõltuvused +dependency=Sõltuvus +dependent=Sõltuv +task_depends=Ülesanne sõltub: +add_dependency=Lisa sõltuvus +task_dependency=See ülesanne sõltub: +add_dependent=Lisa sõltuv + +; Dependency Types (Finish-Start, Finish-Finish, Start-Start, Start-Finish) +fs=FS +ff=FF +ss=SS +sf=SF + +write_a_comment=Kirjuta kommentaar… +save_comment=Salvesta kommentaar + +no_history_available=Ajalugu puudub +add_watcher=Lisa jälgija + +new_sub_project=Alamprojekt +new_task=Uus ülesanne +under_n={0} alam +no_related_issues=Seotud eesmärgid puuduvad + +copy_issue=Kopeeri objekt +copy_issue_details=Objekti kopeerimisel luuakse koopia nii objektist endast kui selle alamobjektidest. Kommentaare, faile, ajalugu ja jälgijaid ei kopeerita. + +deleted_success="Objekt #{0} edukalt kustutatud." +deleted_notice=See objekt on kustutatud. Muutmisel saadetakse jälgijatele teated, ent keskonnas saavad objekti näha üksnes administraatorid. +restore_issue=Taasta objekt + +comment_delete_confirm=Kas oled kindel, et soovid kommentaari kustutada? + +bulk_actions=Masstegevused +bulk_update=Muuda valitud elemendid +update_copy=Uuenda koopiat + +project_overview=Projekti ülevaate +project_tree=Projektipuu +n_complete={0} lõpetatud +n_child_issues={0} alamobjekti +project_no_files_attached=Manused pole lisatud. + +; Tags +issue_tags=Märgistused +no_tags_created=Märgistused puuduvad. +tag_help_1="Märgi objekt lisades #märge selle kirjeldusse." +tag_help_2="Märgis võib sisaldada tähati, numbreid ja sidekriipse. Märgis peab algama alati tähega. (Näide: #märgis)." +view_all_tags=Näita kõiki märgistusi +list=Loend +cloud=Pilv +count=Kokku + +; Taskboard/Backlog +backlog=Tööde kuhi +backlog_points=Punktid +filter_tasks=Filtreeri ülesanded +filter_projects=Filtreeri projektid +all_projects=Kõik projektid +all_tasks=Kõik ülesanded +my_groups=Minu grupid +burndown=Langustrend +hours_remaining=Aega järgi (h) +man_hours=Töötunnid +man_hours_remaining=Töötunde jäänud (h) +project=Projekt +subproject=Alamprojekt +track_time_spent=Jälgi kulutatud aega +burn_hours=Aeg (h) +backlog_old_help_text=Lohistage projektid siia, et eemaldada tsüklist +show_previous_sprints=Näita eelmisi tsükleid +show_future_sprints=Näita järgmisi tsükleid +unsorted_projects=Sorteerimata projektid +unsorted_items=Sortimata + +; Administration +release_available=Uus värsekndus on saadaval! +releases=Vaata väljalaskeid +version=Versioon +update_available=Värskendus saadaval +update_to_n=Uuenda versioonile {0} +backup_db=Kas oled kindel?\nKas oled teinud andmebaasist ja rakenduse kõigist failidest kindluskoopia, et ebaõnnestumise korral oleks võimalik sellest taastada? + +; Admin Overview Tab +overview=Ülevaade +database=Andmebaas +hostname=IP/Host +mailbox=Postkast +schema=Skeem +current_version=Versioon +cache=Puhver +clear_cache=Tühjenda +timeouts=Pausid +queries=Päringud +minify=Pisendatud +attachments=Manused +smtp_not_enabled=SMTP ei ole lubatud. +imap_not_enabled=IMAP ei ole lubatud. +miscellaneous=Varia +session_lifetime=Sessiooni kestus +max_rating=Maksimaalne hinne +mailbox_help=Pole kindel? Vaata kasutajajuhendit! + +; Admin Configuration Tab +; Site Basics Section +site_basics=Saidi üldseaded +site_name=Saidi nimi +site_description=Saidi kirjeldus +timezone=Ajatsoon +default_theme=Kujundusmall +logo=Tunnuspilt +; Issue Types and Statuses Section +issue_types_and_statuses=Liigid ja staatused +issue_types=Liigid +issue_statuses=Staatused +yes=Jah +no=Ei +taskboard_columns=Ülesandetahvli veerud +taskboard_sort=Sordi ülesannetetahvel +; Text Parsing Section +text_parsing=Teksti sõelumine +parser_syntax=Sõelumise süntaks +advanced_options=Täpsemad suvandid (kõik vaikimisi lubatud) +convert_ids=Teisenda ID-d linkideks +convert_hashtags=Teisenda hashtagid linkideks +convert_urls=Teisenda URL-id linkideks +convert_emoticons=Asenda tähemärgid emotikonidega +; Email Section +email_smtp_imap=E-post (SMTP/IMAP) +config_note=Märkus +email_leave_blank=Jäta väli tühjaks, et keelata väljuv e-post. +outgoing_mail=Väljuv e-post (SMTP) +from_address=Saatja +package_mail_config_note={0} kasutab teie PHP mail konfiguratsiooni väljuva e-posti jaoks. +incoming_mail=Sissetulev e-post (IMAP) +imap_truncate_lines=Kärbi IMAP kirjadest +imap_settings_note=IMAP seadistused ei oma mõju kui {0} cron ei ole kasutuses. +; Advanced Section +advanced=Laiendatud +security=Turvalisus +min_pass_len=Parooli miinimumpikkus +censor_credit_card_numbers=Eemalda krediitkaardi numbrid +cookie_expiration=Sessiooniküpsis (sekundites) +max_upload_size=Üleslaadimise mahupiirang (baitides) +allow_public_registration=Luba igaühel kasutajaks registreeruda +core=Tuum +debug_level=Veateated (DEBUG) +cache_mode=Puhverdamine (CACHE) +demo_user=Demo kasutaja (site.demo) +advanced_config_note=Neid seadistusi saab muuta üksnes andmebaasis {0} tabelis. +restrict_access=Piira juurdepääsu +restrict_access_detail=Piira juurdepääsu probleemidele, kasutajatele ja rühmadele probleemi omaniku ja rühma liikmete järgi. + +; Admin Users Tab +new_user=Uus kasutaja +deactivate=Deaktiveeri +reactivate=Aktiveeri uuesti +role=Roll +show_deactivated_users=Näita deaktiveeritud kasutajaid +; User Modal +edit_user=Muuda kasutajat +require_new_password=Nõua järgmisel sisselogimisel parooli vahetamist +user=Kasutaja +administrator=Administraator +rank=Tase +ranks.0=Külaline +ranks.1=Klient +ranks.2=Kasutaja +ranks.3=Mänedžer +ranks.4=Administraator +ranks.5=Peaadministraator +rank_permissions.0=Lugemine +rank_permissions.1=Kommenteerimine +rank_permissions.2=Luua ja muuta eesmärke +rank_permissions.3=Kustutada eesmärke ja kommentaare +rank_permissions.4=Saab luua/muuta/kustutada kasutajaid/gruppe/tsükleid +rank_permissions.5=Saab muuta seadistusi + +; Admin Groups Tab +no_groups_exist=Gruppe pole. +; Groups Modal +members=Liikmed +group_name=Grupi nimi +group_name_saved=Grupi nimi muudetud +manager=Mänedžer +set_as_manager=Lisa mänedžeriks +add_to_group=Lisa gruppi +api_visible=Nähtav API-s + +; Admin Sprints Tab +; Sprint Modal +new_sprint=Uus tsükkel +edit_sprint=Muuda tsüklit +start_date=Alguspäev +end_date=Tähtaeg + +; Hotkey modal +hotkeys=Kiirklahvid +hotkeys_global=Globaalne +show_hotkeys=Näita kiirklahve +reload_page=Lae leht uuesti +press_x_then_y={0} siis {1} +navigation=Navigeerimine +use_with_x=Kasuta koos {0} diff --git a/app/dict/fr.ini b/app/dict/fr.ini index 70d2d921..dbf5dcaa 100644 --- a/app/dict/fr.ini +++ b/app/dict/fr.ini @@ -20,207 +20,219 @@ n_queries={0,number,integer} queries n_total_queries_n_cached={0,number,integer} requêtes, {1,number,integer} mises en cache page_generated_in_n_seconds=Page générée en {0,number} seconde(s) real_usage_n_bytes=Utilisation : {0,number,integer} octets -current_commit_n=Current commit: {0} +current_commit_n=Commit courant : {0} ; Installer install_phproject=Installer Phproject ; Navbar, basic terminology new=Nouveau -Task=Task -Project=Project +Task=Tâche +Project=Projet Bug=Bug sprint=Sprint sprints=Sprints -browse=Parcourir -open=Ouvrir -closed=Fermé +browse=Tickets +open=Ouverts +closed=Fermés created_by_me=Créé par moi -assigned_to_me=Assigned to me -by_type=By type -issue_search=Quickly find an issue +assigned_to_me=Assigné à moi +by_type=Par type +issue_search=Recherche rapide d'un ticket administration=Administration -dashboard=Dashboard -issues=Issues -my_issues=My Issues -my_account=My Account +dashboard=Tableau de bord +issues=Tickets +my_issues=Mes tickets +my_account=Mon Compte configuration=Configuration plugins=Plugins -users=Users -groups=Groups -log_out=Log Out -demo_notice=This site is running in demo mode. All content is public and may be reset at any time. -loading=Loading… -close=Close -related=Related -current_issue=Current issue -toggle_navigation=Toggle Navigation +users=Utilisateurs +groups=Groupes +log_out=Déconnexion +demo_notice=Ce site est une version de démonstration. Tout le contenu est public et peut être réinitialisée à tout moment. +loading=Chargement… +close=Fermer +related=Associé +current_issue=Ticket courant +toggle_navigation=Activer/Désactiver la navigation ; Errors -error.loading_issue_history=Error loading issue history. +error.loading_issue_history=Erreur lors du chargement de l'historique du ticket. error.loading_issue_watchers=Error loading issue watchers. -error.loading_related_issues=Error loading related issues. -error.loading_dependencies=Error loading dependencies. -error.404_text=The page you requested is not available. +error.loading_related_issues=Erreur de chargement de tickets similaires. +error.loading_dependencies=Erreur de chargement des dépendances. +error.404_text=La page demandée n'est pas disponible. ; Dashboard -taskboard=Taskboard -my_projects=My Projects -subprojects=Subprojects -my_subprojects=My Subprojects -my_tasks=My Tasks +taskboard=Tableau des tâches +my_projects=Mes Projets +subprojects=Sous-projets +my_subprojects=Mes Sous-projets +my_tasks=Mes Tâches bug=bug bugs=bugs -my_bugs=My Bugs -repeat_work=Repeat Work +my_bugs=Mes Bugs +repeat_work=Répéter le travail my_watchlist=My Watch List -projects=Projects -add_project=Add Project -add_task=Add Task -add_bug=Add Bug -my_comments=My Comments -recent_comments=Recent Comments -open_comments=Open Comments -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +projects=Projets +add_project=Ajouter un projet +add_task=Ajouter une tâche +add_bug=Ajouter un bug +my_comments=Mes commentaires +recent_comments=Commentaires récents +open_comments=Ouvrir les commentaires +add_widgets=Ajouter un widget +no_more_widgets=Pas plus de widgets disponibles +no_matching_issues=Aucun ticket correspondant +parent_project=Projet parent + +manage_widgets=Gérer Les Widgets +available_widgets=Les Widgets Disponibles +enabled_widgets=Activer les Widgets ; User pages -created_issues=Created Issues -assigned_issues=Assigned Issues -overdue_issues=Overdue Issues -issue_tree=Issue Tree +created_issues=Tickets créés +assigned_issues=Tickets assignés +overdue_issues=Tickets en retard +issue_tree=Arbre des tickets ; Account -name=Name -theme=Theme -language=Language -task_color=Task Color +name=Nom +theme=Thème +language=Langue +task_color=Couleur de la tâche avatar=Avatar -edit_on_gravatar=Edit on Gravatar -save=Save -current_password=Current password -profile=Profile -settings=Settings -default=Default -disable_editor=Disable Editor +edit_on_gravatar=Editer sur Gravatar +save=Enregistrer +current_password=Mot de passe actuel +profile=Profil +settings=Paramètres +default=Par défaut +disable_editor=Désactiver l’éditeur +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Désactivez les notifications pour les propres actions ; Browse -general=General -exact_match=Exact Match -submit=Submit -export=Export -go_previous=Previous -go_next=Next -previous_sprints=Previous Sprints -deactivated_users=Deactivated Users +general=Général +exact_match=Correspondance exacte +submit=Envoyer +export=Exporter +go_previous=Précédent +go_next=Suivant +previous_sprints=Sprints précédents +deactivated_users=Utilisateurs désactivés ; Issue fields cols.id=ID -cols.title=Title +cols.title=Titre +cols.size_estimate=Estimation de la taille cols.description=Description cols.type=Type -cols.priority=Priority -cols.status=Status -cols.author=Author -cols.assignee=Assigned To -cols.total_spent_hours=Total Spent Hours -cols.hours_total=Planned Hours -cols.hours_remaining=Remaining Hours -cols.start_date=Start Date -cols.due_date=Due Date -cols.repeat_cycle=Repeat Cycle -cols.parent_id=Parent ID +cols.priority=Priorité +cols.status=Statut +cols.author=Auteur +cols.assignee=Assigné à +cols.total_spent_hours=Total des heures passées +cols.hours_total=Heures prévues +cols.hours_remaining=Heures restantes +cols.start_date=Date de début +cols.due_date=Date d’échéance +cols.repeat_cycle=Répéter le cycle +cols.parent_id=ID du parent cols.parent=Parent cols.sprint=Sprint -cols.created=Created -cols.start=Start -cols.due=Due +cols.due_date_sprint=Date d'échéance fixée par Sprint +cols.created=Créé +cols.start=Début +cols.due=Échéance cols.closed_date=Fermé -cols.hours_spent=Hours Spent -cols.depends_on=Depends On +cols.hours_spent=Heures passées +cols.depends_on=Dépend de ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Actif +New=Nouveau +On Hold=En attente +Completed=Terminé ; Issue Priorities -High=High -Normal=Normal -Low=Low +High=Maximum +Normal=Normale +Low=Minimum ; Issue editing -not_assigned=Not Assigned -choose_option=Choose an option +not_assigned=Non assigné +choose_option=Choisir une option +set_sprint_from_due_date=Ajouter au sprint par date d'échéance -repeating=Repeating -not_repeating=Not Repeating -daily=Daily -weekly=Weekly -monthly=Monthly +repeating=Récurrent +not_repeating=Non récurrent +daily=Quotidiennement +weekly=Hebdomadairement +monthly=Mensuellement +quarterly=Trimestriel +semi_annually=Semestriel +annually=Annually -no_sprint=No Sprint +no_sprint=Aucun Sprint -comment=Comment +comment=Commentaire -send_notifications=Send notifications -reset=Reset -new_n=New {0} -edit_n=Edit {0} -create_n=Create {0} -save_n=Save {0} +send_notifications=Envoyer des notifications +reset=Reinitialiser +new_n=Nouveau {0} +edit_n=Editer {0} +create_n=Créer {0} +save_n=Enregistrer {0} -unsaved_changes=You have unsaved changes. +unsaved_changes=Vous avez des modifications non enregistrées. ; Issue page -mark_complete=Mark Complete -complete=Complete -reopen=Reopen -show_on_taskboard=Show on Taskboard -copy=Copy -watch=Watch -unwatch=Unwatch -edit=Edit -delete=Delete - -files=Files +mark_complete=Marquer comme terminé +complete=Terminé +reopen=Réouvrir +show_on_taskboard=Afficher sur le tableau des tâches +copy=Copier +watch=Suivre +unwatch=Ne plus suivre +edit=Éditer +delete=Supprimer + +files=Fichiers upload=Upload -upload_a_file=Upload a File -attached_file=Attached file -deleted=Deleted -file_name=File Name -uploaded_by=Uploaded By -upload_date=Upload Date -file_size=File Size -file_deleted=File deleted. -undo=Undo - -comments=Comments -history=History -watchers=Watchers -child_task=Child Task -child_tasks=Child Tasks -sibling_tasks=Sibling Tasks -related_task=Related Task -related_tasks=Related Tasks - -notifications_sent=Notifications sent -notifications_not_sent=Notifications not sent -a_changed={0} changed: -a_changed_from_b_to_c={0} changed from {1} to {2} +upload_a_file=Uploader un fichier +attached_file=Fichier joint +deleted=Supprimé(e) +file_name=Nom du fichier +uploaded_by=Mis en ligne par +upload_date=Date de mise en ligne +file_size=Taille du fichier +file_deleted=Fichier supprimé. +undo=Annuler + +comments=Commentaires +history=Historique +watchers=Observateurs +child_task=Tâche enfante +child_tasks=Tâches enfantes +sibling_tasks=Tâches connexes +related_task=Tâche associée +related_tasks=Tâches associées + +notifications_sent=Notifications envoyées +notifications_not_sent=Notifications non envoyées +a_changed={0} changé: +a_changed_from_b_to_c={0} changé de {1} à {2} a_set_to_b={0} set to {1} -a_removed={0} removed +a_removed={0} supprimé -dependencies=Dependencies -dependency=Dependency -dependent=Dependent -task_depends=This task depends on: -add_dependency=Add Dependency -task_dependency=This task is a dependency for: -add_dependent=Add Dependent +dependencies=Dépendances +dependency=Dépendance +dependent=Dépendant +task_depends=Cette tâche dépend de : +add_dependency=Ajouter une dépendance +task_dependency=Cette tâche est une dépendance pour : +add_dependent=Ajouter un dépendant ; Dependency Types (Finish-Start, Finish-Finish, Start-Start, Start-Finish) fs=FS @@ -228,176 +240,181 @@ ff=FF ss=SS sf=SF -write_a_comment=Write a comment… -save_comment=Save Comment +write_a_comment=Écrire un commentaire… +save_comment=Enregistrer le commentaire -no_history_available=No history available -add_watcher=Add Watcher +no_history_available=Aucun historique disponible +add_watcher=Ajouter un observateur -new_sub_project=New Sub-Project -new_task=New Task -under_n=Under {0} -no_related_issues=No related issues +new_sub_project=Nouveau sous-projet +new_task=Nouvelle tâche +under_n=Sous {0} +no_related_issues=Aucun ticket associé -copy_issue=Copy Issue +copy_issue=Copier le ticket copy_issue_details=Copying this issue will duplicate it and all of its descendants. No comments, files, history, or watchers will be copied. -deleted_success=Issue #{0} successfully deleted. +deleted_success=Le ticket #{0} a été correctement supprimé. deleted_notice=This issue has been deleted. Editing it will still send notifications, but the recipients will not be able to view the issue unless they are administrators. -restore_issue=Restore Issue +restore_issue=Restaurer le ticket -comment_delete_confirm=Are you sure you want to delete this comment? +comment_delete_confirm=Êtes-vous sûr de vouloir supprimer ce commentaire ? bulk_actions=Show Bulk Actions -bulk_update=Update All Selected Tasks +bulk_update=Mettre à jour les tâches sélectionnées +update_copy=Update a copy -project_overview=Project Overview -project_tree=Project Tree -n_complete={0} complete +project_overview=Aperçu du projet +project_tree=Arborescence du projet +n_complete={0} achevé n_child_issues={0} child issues ; Tags issue_tags=Issue Tags -no_tags_created=No issue tags have been created yet. +no_tags_created=Aucun ticket n'a encore été crée. tag_help_1=Tag an issue by adding a #hashtag to its description. -tag_help_2=Hashtags can contain letters, numbers, and hyphens, and must start with a letter. -view_all_tags=View all tags -list=List +tag_help_2=Hashtags peut contenir des lettres, des chiffres et des traits d’union et doit commencer par une lettre. +view_all_tags=Afficher tous les tags +list=Liste cloud=Cloud -count=Count +count=Nombre ; Taskboard/Backlog backlog=Backlog -filter_tasks=Filter Tasks -filter_projects=Filter Projects -all_projects=All Projects -all_tasks=All Tasks -my_groups=My Groups +backlog_points=Points +filter_tasks=Filtrer les tâches +filter_projects=Filtrer les projets +all_projects=Tous les projets +all_tasks=Toutes les tâches +my_groups=Mes groupes burndown=Burndown -hours_remaining=Hours Remaining -ideal_hours_remaining=Ideal Hours Remaining -daily_hours_remaining=Daily Hours Remaining -project=Project -subproject=Subproject +hours_remaining=Heures restantes +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining +project=Projet +subproject=Sous-projet track_time_spent=Track Time Spent -burn_hours=Burn hours -backlog_old_help_text=Drag projects here to remove from a sprint -show_previous_sprints=Show Previous Sprints -show_future_sprints=Show Future Sprints -unsorted_projects=Unsorted Projects +burn_hours=Heures perdues +backlog_old_help_text=Faites glisser les projets ici pour les supprimer d'un sprint +show_previous_sprints=Afficher les Sprints précédent +show_future_sprints=Afficher les prochains Sprints +unsorted_projects=Projets non triés +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Version -update_available=Update Available -update_to_n=Update to {0} -backup_db=Are you sure?\nYou should back up your database before you proceed. +update_available=Mise à jour disponible +update_to_n=Mise à jour vers {0} +backup_db=Êtes-vous sûr?\nVous devez sauvegarder votre base de données avant de poursuivre. ; Admin Overview Tab -overview=Overview -database=Database -hostname=Hostname -schema=Schema -current_version=Current Version +overview=Vue d’ensemble +database=Base de données +hostname=Nom d’hôte +schema=Schéma +current_version=Version actuelle cache=Cache -clear_cache=Clear Cache -timeouts=Timeouts -queries=Queries -minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime -max_rating=Max Rating +clear_cache=Vider le cache +timeouts=Délais d’attente +queries=Requêtes +minify=Réduire +attachments=Pièces jointes +smtp_not_enabled=SMTP n’est pas activé. +imap_not_enabled=IMAP n’est pas activé. +miscellaneous=Divers +session_lifetime=Durée de vie de la session +max_rating=Note maximale ; Admin Configuration Tab ; Site Basics Section site_basics=Site Basics -site_name=Site Name -site_description=Site Description -timezone=Timezone -default_theme=Default Theme +site_name=Nom du site +site_description=Description du site +timezone=Fuseau horaire +default_theme=Thème par défaut logo=Logo ; Issue Types and Statuses Section -issue_types_and_statuses=Issue Types and Statuses -issue_types=Issue Types -issue_statuses=Issue Statuses -yes=Yes -no=No +issue_types_and_statuses=Types et statuts des tickets +issue_types=Types des tickets +issue_statuses=Statuts des tickets +yes=Oui +no=Non taskboard_columns=Taskboard Columns taskboard_sort=Taskboard Sort ; Text Parsing Section -text_parsing=Text Parsing -parser_syntax=Parser Syntax -advanced_options=Advanced Options (all enabled by default) -convert_ids=Convert IDs to Links -convert_hashtags=Convert Hashtags to Links -convert_urls=Convert URLs to links -convert_emoticons=Convert emoticons to glyphs +text_parsing=Analyse du texte +parser_syntax=Analyseur de syntaxe +advanced_options=Options avancées (toutes activées par défaut) +convert_ids=Convertir les ID en liens +convert_hashtags=Convertir les Hashtags en liens +convert_urls=Convertir les URL en liens +convert_emoticons=Convert text emoticons to Emoji ; Email Section -email_smtp_imap=Email (SMTP/IMAP) -config_note=Note -email_leave_blank=Leave blank to disable outgoing email -outgoing_mail=Outgoing Mail (SMTP) -from_address=From Address -package_mail_config_note={0} uses your default PHP mail configuration for outgoing email. -incoming_mail=Incoming Mail (IMAP) -imap_truncate_lines=IMAP Message Truncate Lines +email_smtp_imap=E-mail (SMTP/IMAP) +config_note=Remarque +email_leave_blank=Laissez vide pour désactiver le courrier sortant +outgoing_mail=Courrier sortant (SMTP) +from_address=Adresse de l'expéditeur +package_mail_config_note={0} utilise votre configuration de messagerie PHP par défaut pour le courrier sortant. +incoming_mail=Courrier entrant (IMAP) +imap_truncate_lines=Lignes de troncature message IMAP imap_settings_note=IMAP settings here will have no effect unless the {0} cron is being run. ; Advanced Section -advanced=Advanced -security=Security -min_pass_len=Minimum Password Length -censor_credit_card_numbers=Censor Credit Card Numbers -cookie_expiration=Cookie Expiration (seconds) -max_upload_size=Max Upload Size (bytes) -allow_public_registration=Allow public registration -core=Core -debug_level=Debug Level (DEBUG) -cache_mode=Cache Mode (CACHE) -demo_user=Demo User (site.demo) -advanced_config_note=These values can be changed by editing the values in the {0} database table. +advanced=Avancé +security=Sécurité +min_pass_len=Longueur minimale du mot de passe +censor_credit_card_numbers=Censurer les numéros de carte de crédit +cookie_expiration=Expiration du cookie (secondes) +max_upload_size=Taille maximale d'upload ( enoctets) +allow_public_registration=Permettre l’inscription publique +core=Noyau +debug_level=Niveau de débogage (DEBUG) +cache_mode=Mode de cache (CACHE) +demo_user=Utilisateur démo (site.demo) +advanced_config_note=Ces valeurs peuvent être modifiées en modifiant les valeurs dans la table {0} de la base de données. ; Admin Users Tab -new_user=New User -deactivate=Deactivate -reactivate=Reactivate -role=Role -show_deactivated_users=Show Deactivated Users +new_user=Nouvel utilisateur +deactivate=Désactiver +reactivate=Réactiver +role=Rôle +show_deactivated_users=Afficher les utilisateurs désactivés ; User Modal -edit_user=Edit User -require_new_password=Require a new password on next login -user=User -administrator=Administrator -rank=Rank -ranks.0=Guest +edit_user=Modifier l'utilisateur +require_new_password=Demander un nouveau mot de passe à la prochaine connexion +user=Utilisateur +administrator=Administrateur +rank=Rang +ranks.0=Invité ranks.1=Client -ranks.2=User +ranks.2=Utilisateur ranks.3=Manager -ranks.4=Admin -ranks.5=Super Admin -rank_permissions.0=Read-only -rank_permissions.1=Can post comments -rank_permissions.2=Can create/edit issues -rank_permissions.3=Can delete issues/comments -rank_permissions.4=Can create/edit/delete users/groups/sprints -rank_permissions.5=Can edit configuration +ranks.4=Administrateur +ranks.5=Super administrateur +rank_permissions.0=Lecture seule +rank_permissions.1=Peut poster des commentaires +rank_permissions.2=Peut créer/éditer des tickets +rank_permissions.3=Peut supprimer des questions/commentaires +rank_permissions.4=Peut créer/modifier/supprimer des utilisateurs/groupes/sprints +rank_permissions.5=Peut modifier la configuration ; Admin Groups Tab -no_groups_exist=No groups exist. +no_groups_exist=Aucun groupe. ; Groups Modal -members=Members -group_name=Group Name -group_name_saved=Group name saved +members=Membres +group_name=Nom du groupe +group_name_saved=Nom du groupe sauvegardé manager=Manager -set_as_manager=Set as Manager -add_to_group=Add to Group -api_visible=Visible to API +set_as_manager=Définir comme manager +add_to_group=Ajouter au groupe +api_visible=Visible depuis l'API ; Admin Sprints Tab ; Sprint Modal -new_sprint=New Sprint -edit_sprint=Edit Sprint -start_date=Start Date -end_date=End Date +new_sprint=Nouveau Sprint +edit_sprint=Editer le Sprint +start_date=Date de début +end_date=Date de fin diff --git a/app/dict/it.ini b/app/dict/it.ini index 5e4ef4be..7919715c 100644 --- a/app/dict/it.ini +++ b/app/dict/it.ini @@ -28,7 +28,7 @@ install_phproject=Installazione Phproject ; Navbar, basic terminology new=Nuovo Task=Task -Project=Project +Project=Progetto Bug=Bug sprint=Sprint sprints=Elenco sprint @@ -66,7 +66,7 @@ error.404_text=La pagina che hai richiesto non è disponibile. ; Dashboard taskboard=Taskboard my_projects=I miei progetti -subprojects=Subprojects +subprojects=Sottoprogetti my_subprojects=I miei sottoprogetti my_tasks=I miei compiti bug=bug @@ -74,17 +74,21 @@ bugs=bugs my_bugs=I miei bug repeat_work=Attività ricorrente my_watchlist=Elenco attività seguite -projects=Projects +projects=Progetti add_project=Aggiungi progetto add_task=Aggiungi compito add_bug=Aggiungi un bug my_comments=I miei commenti recent_comments=Commenti recenti open_comments=Commenti aperti -add_widgets=Add Widgets -no_more_widgets=No more widgets available +add_widgets=Aggiungi Widget +no_more_widgets=Nessun widget disponibile no_matching_issues=No matching issues -parent_project=Parent Project +parent_project=Progetto principale + +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets ; User pages created_issues=Richieste create @@ -105,6 +109,8 @@ profile=Profilo settings=Impostazioni default=Predefinito disable_editor=Disabilita l'editor +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=Generale @@ -119,6 +125,7 @@ deactivated_users=Utenti disattivati ; Issue fields cols.id=ID cols.title=Titolo +cols.size_estimate=Size Estimate cols.description=Descrizione cols.type=Tipo cols.priority=Priorità @@ -134,6 +141,7 @@ cols.repeat_cycle=Ripeti ciclo cols.parent_id=ID genitore cols.parent=Genitore cols.sprint=Sprint +cols.due_date_sprint=Set Due Date by Sprint cols.created=Creato cols.start=Inizio cols.due=Termine di consegna @@ -142,25 +150,29 @@ cols.hours_spent=Ore impiegate cols.depends_on=Dipende da ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Attivo +New=Nuovo +On Hold=In sospeso +Completed=Completato ; Issue Priorities -High=High -Normal=Normal -Low=Low +High=Alta +Normal=Normale +Low=Bassa ; Issue editing not_assigned=Non Assegnato choose_option=Scegli un'opzione +set_sprint_from_due_date=Add to sprint by due date repeating=Ricorrente not_repeating=Non ricorrente daily=Quotidiano weekly=Settimanale monthly=Mensile +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=Nessuno sprint @@ -250,6 +262,7 @@ comment_delete_confirm=Vuoi davvero eliminare questo commento? bulk_actions=Mostra azioni di massa bulk_update=Aggiorna tutti i compiti selezionati +update_copy=Update a copy project_overview=Panoramica progetto project_tree=Struttura del progetto @@ -268,6 +281,7 @@ count=Conteggio ; Taskboard/Backlog backlog=Backlog +backlog_points=Points filter_tasks=Filtra compiti filter_projects=Filtra Progetti all_projects=Tutti i progetti @@ -275,8 +289,8 @@ all_tasks=Tutti i compiti my_groups=I miei gruppi burndown=Burn-down hours_remaining=Ore rimanenti -ideal_hours_remaining=Ideale ore rimanenti -daily_hours_remaining=Ore quotidiane rimanenti +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining project=Progetto subproject=Sottoprogetto track_time_spent=Tieni traccia del tempo trascorso @@ -285,8 +299,11 @@ backlog_old_help_text=Trascinare i progetti qui per rimuoverli da uno sprint show_previous_sprints=Visualizza sprint precedenti show_future_sprints=Visualizzare I prossimi sprint unsorted_projects=Unsorted Projects +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Versione update_available=Aggiornamento disponibile update_to_n=Aggiornamento a {0} @@ -299,15 +316,15 @@ hostname=Nome host schema=Schema current_version=Versione Attuale cache=Cache -clear_cache=Clear Cache +clear_cache=Cancella Cache timeouts=Timeouts queries=Queries minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime +attachments=Allegati +smtp_not_enabled=SMTP non abilitato. +imap_not_enabled=IMAP non abilitato. +miscellaneous=Vario +session_lifetime=Durata della sessione max_rating=Max Rating ; Admin Configuration Tab @@ -322,7 +339,7 @@ logo=Logo issue_types_and_statuses=Issue Types and Statuses issue_types=Issue Types issue_statuses=Issue Statuses -yes=Yes +yes=Sì no=No taskboard_columns=Taskboard Columns taskboard_sort=Taskboard Sort @@ -333,7 +350,7 @@ advanced_options=Opzioni avanzate (tutte abilitate per impostazione predefinita) convert_ids=Convertire gli ID in collegamenti convert_hashtags=Convertire gli hashtag in collegamenti convert_urls=Convertire gli URL in collegamenti -convert_emoticons=Convertire emoticon in glifi +convert_emoticons=Convert text emoticons to Emoji ; Email Section email_smtp_imap=E-mail (SMTP/IMAP) config_note=Nota diff --git a/app/dict/ja.ini b/app/dict/ja.ini index 17abd4ab..d2834b92 100644 --- a/app/dict/ja.ini +++ b/app/dict/ja.ini @@ -86,6 +86,10 @@ no_more_widgets=No more widgets available no_matching_issues=No matching issues parent_project=Parent Project +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets + ; User pages created_issues=Created Issues assigned_issues=Assigned Issues @@ -105,6 +109,8 @@ profile=プロフィール settings=設定 default=Default disable_editor=Disable Editor +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=General @@ -119,6 +125,7 @@ deactivated_users=Deactivated Users ; Issue fields cols.id=ID cols.title=Title +cols.size_estimate=Size Estimate cols.description=Description cols.type=Type cols.priority=Priority @@ -134,6 +141,7 @@ cols.repeat_cycle=Repeat Cycle cols.parent_id=Parent ID cols.parent=Parent cols.sprint=Sprint +cols.due_date_sprint=Set Due Date by Sprint cols.created=Created cols.start=Start cols.due=Due @@ -155,12 +163,16 @@ Low=Low ; Issue editing not_assigned=Not Assigned choose_option=Choose an option +set_sprint_from_due_date=Add to sprint by due date repeating=Repeating not_repeating=Not Repeating daily=毎日 weekly=毎週 monthly=毎月 +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=No Sprint @@ -249,7 +261,8 @@ restore_issue=Restore Issue comment_delete_confirm=Are you sure you want to delete this comment? bulk_actions=Show Bulk Actions -bulk_update=Update All Selected Tasks +bulk_update=Update selected items +update_copy=Update a copy project_overview=Project Overview project_tree=Project Tree @@ -268,6 +281,7 @@ count=Count ; Taskboard/Backlog backlog=Backlog +backlog_points=Points filter_tasks=Filter Tasks filter_projects=Filter Projects all_projects=All Projects @@ -275,8 +289,8 @@ all_tasks=All Tasks my_groups=My Groups burndown=Burndown hours_remaining=Hours Remaining -ideal_hours_remaining=Ideal Hours Remaining -daily_hours_remaining=Daily Hours Remaining +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining project=プロジェクト subproject=サブプロジェクト track_time_spent=Track Time Spent @@ -285,8 +299,11 @@ backlog_old_help_text=Drag projects here to remove from a sprint show_previous_sprints=Show Previous Sprints show_future_sprints=Show Future Sprints unsorted_projects=Unsorted Projects +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Version update_available=Update Available update_to_n=Update to {0} @@ -333,7 +350,7 @@ advanced_options=Advanced Options (all enabled by default) convert_ids=Convert IDs to Links convert_hashtags=Convert Hashtags to Links convert_urls=Convert URLs to links -convert_emoticons=Convert emoticons to glyphs +convert_emoticons=Convert text emoticons to Emoji ; Email Section email_smtp_imap=Eメール(SMTP/IMAP) config_note=メモ diff --git a/app/dict/ko.ini b/app/dict/ko.ini new file mode 100644 index 00000000..c7d56e24 --- /dev/null +++ b/app/dict/ko.ini @@ -0,0 +1,431 @@ +; Index, login, password reset +log_in=로그인 +register=가입 +username=사용자 이름 +password=비밀번호 +email=이메일 +email_address=이메일 주소 + +reset_password=비밀번호 재설정 +new_password=새 비밀번호 +confirm_password=비밀번호 재입력 + +cancel=취소 + +logged_out=로그 아웃 +session_ended_message=세션이 만료되었습니다. 다시 로그인하십시오. + +; Footer +n_queries={0,number,integer} queries +n_total_queries_n_cached={0,number,integer} 총 쿼리 {1,number,integer} 캐시 +page_generated_in_n_seconds={0, number} 초 내 생성 된 페이지 +real_usage_n_bytes=실제로 이용한 바이트 : {0,number,integer} +current_commit_n=현재 커밋: {0} + +; Installer +install_phproject=Phproject 설치 + +; Navbar, basic terminology +new=새로 만들기 +Task=작업 +Project=프로젝트 +Bug=버그 +sprint=스프린트 +sprints=스프린트 +browse=보기 +open=오픈 +closed=종료 +created_by_me=내가 생성함 +assigned_to_me=나에게 할당됨 +by_type=유형별 +issue_search=빠른 이슈 찾기 +administration=관리 +dashboard=대시 보드 +issues=이슈 +my_issues=내 이슈 +my_account=내 계정 +configuration=환경설정 +plugins=플러그인 +users=사용자 +groups=그룹 +log_out=로그아웃 +demo_notice=이 사이트는 데모 모드로 실행됩니다. 모든 콘텐츠는 공개되지 언제든지 재설정 할 수 있습니다. +loading=로딩 중… +close=닫기 +related=관련 +current_issue=현재 이슈 +toggle_navigation=내비게이션 전환 + +; Errors +error.loading_issue_history=이슈 기록 로드 에러 +error.loading_issue_watchers=이슈 감시자 로드 에러 +error.loading_related_issues=관련 이슈 로드 에러 +error.loading_dependencies=종속성 로드 에러 +error.404_text=요청한 페이지를 사용할 수 없습니다. + +; Dashboard +taskboard=Taskboard +my_projects=내 프로젝트 +subprojects=하위 프로젝트 +my_subprojects=내 하위 프로젝트 +my_tasks=내 작업 +bug=버그 +bugs=버그 +my_bugs=내 버그 +repeat_work=반복 작업 +my_watchlist=내 감시 목록 +projects=프로젝트 +add_project=프로젝트 추가 +add_task=작업 추가 +add_bug=버그 추가 +my_comments=내 댓글 +recent_comments=최근 댓글 +open_comments=댓글 열기 +add_widgets=위젯 추가 +no_more_widgets=더 이상 위젯을 사용할 수 없습니다. +no_matching_issues=일치하는 이슈 없음 +parent_project=부모 프로젝트 + +manage_widgets=위젯 관리 +available_widgets=사용 가능한 위젯 +enabled_widgets=위젯 사용 + +; User pages +created_issues=이슈 생성 +assigned_issues=할당 된 이슈 +overdue_issues=지연 된 이슈 +issue_tree=이슈 트리 + +; Account +name=이름 +theme=Theme +language=언어 +task_color=작업 색상 +avatar=아바타 +edit_on_gravatar=그라아바타에서 편집 +save=저장 +current_password=현재 비밀번호 +profile=프로필 +settings=설정 +default=기본 +disable_editor=편집기 사용 안 함 +disable_due_alerts=기한이 지난 이슈 이메일 비활성화 +disable_self_notifications=자신의 행동에 대한 알림 사용 안 함 + +; Browse +general=일반 +exact_match=정확히 일치 +submit=전송 +export=내보내기 +go_previous=이전 +go_next=다음 +previous_sprints=이전 스프린트 +deactivated_users=비활성화된 사용자 + +; Issue fields +cols.id=ID +cols.title=제목 +cols.size_estimate=예상 시간 +cols.description=설명 +cols.type=유형: +cols.priority=우선 순위 +cols.status=상태 +cols.author=작성자 +cols.assignee=할당 +cols.total_spent_hours=총 소요 시간 +cols.hours_total=계획된 시간 +cols.hours_remaining=남은 시간 +cols.start_date=시작일 +cols.due_date=마감일 +cols.repeat_cycle=반복주기 +cols.parent_id=상위 ID +cols.parent=상위 +cols.sprint=스프린트 +cols.due_date_sprint=스프린트 마감일 설정 +cols.created=생성일 +cols.start=시작 +cols.due=마감일: +cols.closed_date=종료 +cols.hours_spent=소요시간 +cols.depends_on=의존성 + +; Issue Statuses +Active=진행중 +New=새로 만들기 +On Hold=보류 +Completed=완료 + +; Issue Priorities +High=높음 +Normal=보통 +Low=낮음 + +; Issue editing +not_assigned=할당되지 않음 +choose_option=옵션을 선택하세요 +set_sprint_from_due_date=마감일까지 스프린트에 추가 + +repeating=반복 +not_repeating=반복되지 않음 +daily=매일 +weekly=매주 +monthly=매달 +quarterly=분기별 +semi_annually=반기 +annually=매년 + +no_sprint=스프린트 없음 + +comment=댓글 + +send_notifications=알림 보내기 +reset=초기화 +new_n=새로운 {0} +edit_n={0} 편집 +create_n={0} 생성 +save_n={0} 저장 + +unsaved_changes=저장되지 않은 변경 내용이 있습니다. + +; Issue page +mark_complete=마크 완료 +complete=완료 +reopen=다시오픈 +show_on_taskboard=Taskboard 보기 +copy=복사 +watch=감시 +unwatch=감시해제 +edit=편집 +delete=삭제 + +files=파일 +upload=업로드 +upload_a_file=파일을 업로드 +attached_file=파일 첨부 +deleted=삭제 +file_name=파일 이름 +uploaded_by=업로드: +upload_date=업로드 날짜 +file_size=파일 크기 +file_deleted=파일이 삭제되었습니다 +undo=실행 취소 + +comments=댓글 +history=기록 +watchers=감시자 +child_task=자식 작업 +child_tasks=자식 작업 +sibling_tasks=형제 작업 +related_task=관련 작업 +related_tasks=관련 작업 + +notifications_sent=알림 전송 +notifications_not_sent=알림을 전송하지 않음 +a_changed={0} 이 (가) 변경: +a_changed_from_b_to_c={0} 가 {1}에서 {2}로 변경되었습니다. +a_set_to_b={0}을 {1}로 설정 +a_removed={0} 이(가) 제거되었습니다 + +dependencies=의존성 +dependency=Dependency +dependent=Dependent +task_depends=이 작업은 다음에 의존합니다: +add_dependency=의존성 추가 +task_dependency=이 작업은 다음에 대한 종속성입니다: +add_dependent=종속성 추가 + +; Dependency Types (Finish-Start, Finish-Finish, Start-Start, Start-Finish) +fs=완료-시작 +ff=완료-완료 +ss=시작-시작 +sf=시작-완료 + +write_a_comment=댓글 쓰기… +save_comment=댓글 저장 + +no_history_available=사용 가능한 기록이 없습니다. +add_watcher=감시자 추가 + +new_sub_project=새 하위 프로젝트 +new_task=새 작업 +under_n={0} 의 하위 +no_related_issues=관련 이슈 없음 + +copy_issue=이슈 복사 +copy_issue_details=이 이슈를 복사하면 해당 이슈와 모든 하위 이슈가 복사됩니다. 댓글, 파일, 기록 또는 감시자는 복사되지 않습니다. + +deleted_success="이슈 #{0} 가 성공적으로 삭제되었습니다." +deleted_notice=이 이슈는 삭제되었습니다. 편집하면 알림이 계속 전송되지만 관리자는 이슈를 볼 수 없습니다. +restore_issue=이슈 복원 + +comment_delete_confirm=이 댓글을 삭제하시겠습니까? + +bulk_actions=대량 작업 보기 +bulk_update=선택한 항목 업데이트 +update_copy=사본 업데이트 + +project_overview=프로젝트 개요 +project_tree=프로젝트 트리 +n_complete={0} 완료 +n_child_issues={0} 자식 이슈 +project_no_files_attached=이 프로젝트 이슈에 첨부된 파일이 없습니다. + +; Tags +issue_tags=이슈 태그 +no_tags_created=이슈 태그가 아직 만들어지지 않았습니다. +tag_help_1="설명에 #hashtag 추가하여 이슈에 태그를 붙입니다." +tag_help_2=해시태그에는 문자, 숫자 및 하이픈이 포함될 수 있으며 문자로 시작해야 합니다. +view_all_tags=모든 태그 보기 +list=목록 +cloud=태그 클라우드 +count=개수 + +; Taskboard/Backlog +backlog=백로그 +backlog_points=Points +filter_tasks=작업 필터링 +filter_projects=프로젝트 필터링 +all_projects=모든 프로젝트 +all_tasks=모든 작업 +my_groups=내 그룹 +burndown=Burndown +hours_remaining=남은 시간 +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining +project=프로젝트 +subproject=하위 프로젝트 +track_time_spent=소요된 시간 추적 +burn_hours=Burn hours +backlog_old_help_text=스프린트에서 제거하려면 프로젝트를 여기로 드래그하십시오. +show_previous_sprints=이전 스프린트 표시 +show_future_sprints=미래 스프린트 표시 +unsorted_projects=정렬되지 않은 프로젝트 +unsorted_items=정렬되지 않은 항목 + +; Administration +release_available=새로운 릴리스를 사용할 수 있습니다! +releases=릴리스 보기 +version=버전 +update_available=업데이트 가능 +update_to_n={0} 으로 업데이트 +backup_db=확실한가요?\n계속하기 전에 데이터베이스를 백업해야 합니다. + +; Admin Overview Tab +overview=개요 +database=데이터베이스 +hostname=호스트명 +mailbox=사서함 +schema=Schema +current_version=현재 버전 +cache=캐시 +clear_cache=캐시 지우기 +timeouts=타임 아웃 +queries=쿼리 +minify=작게 +attachments=첨부 파일 +smtp_not_enabled=SMTP가 활성화되어 있지 않습니다. +imap_not_enabled=IMAP 가 활성화되어 있지 않습니다. +miscellaneous=기타 +session_lifetime=세션 수명 +max_rating=Max Rating +mailbox_help=무엇을 사용해야 할지 잘 모르시나요? 설명서를 참조하십시오. + +; Admin Configuration Tab +; Site Basics Section +site_basics=사이트 기본 사항 +site_name=사이트 이름 +site_description=사이트 설명 +timezone=시간대 +default_theme=기본 테마 +logo=로고 +; Issue Types and Statuses Section +issue_types_and_statuses=이슈 유형 및 상태 +issue_types=이슈 유형 +issue_statuses=이슈 상태 +yes=예 +no=아니오 +taskboard_columns=Taskboard 열 +taskboard_sort=Taskboard 정렬 +; Text Parsing Section +text_parsing=텍스트 구문 분석 +parser_syntax=파서 구문 +advanced_options=고급 옵션(모두 기본적으로 사용 가능) +convert_ids=ID를 링크로 변환 +convert_hashtags=해시태그를 링크로 변환 +convert_urls=URL을 링크로 변환 +convert_emoticons=텍스트 이모티콘을 Emoji로 변환 +; Email Section +email_smtp_imap=이메일(SMTP/IMAP) +config_note=참고 +email_leave_blank=발신 이메일을 비활성화하려면 비워 두십시오. +outgoing_mail=발송 메일(SMTP) +from_address=발신인 주소 +package_mail_config_note={0} 은 발신 이메일에 기본 PHP 메일 구성을 사용합니다. +incoming_mail=수신 메일(IMAP) +imap_truncate_lines=IMAP 메시지 잘림 라인 +imap_settings_note=IMAP settings here will have no effect unless the {0} cron is being run. +; Advanced Section +advanced=고급 +security=보안 +min_pass_len=최소 비밀번호 길이 +censor_credit_card_numbers=검열 신용 카드 번호 +cookie_expiration=쿠키 만료(초) +max_upload_size=최대 업로드 크기(bytes) +allow_public_registration=공개 등록 허용 +core=Core +debug_level=디버그 레벨(DEBUG) +cache_mode=캐시 모드(CACHE) +demo_user=데모 사용자(site.demo) +advanced_config_note=이 값은 {0} 데이터베이스 테이블의 값을 편집하여 변경할 수 있습니다. + +; Admin Users Tab +new_user=새 사용자 +deactivate=비활성화 +reactivate=재활성화 +role=권한 +show_deactivated_users=비활성화된 사용자 표시 +; User Modal +edit_user=사용자 편집 +require_new_password=다음 로그인시 새 암호 필요 +user=사용자 +administrator=관리자 +rank=순위 +ranks.0=게스트 +ranks.1=클라이언트 +ranks.2=사용자 +ranks.3=매니저 +ranks.4=관리자 +ranks.5=슈퍼 관리자 +rank_permissions.0=읽기 전용 +rank_permissions.1=댓글을 작성 할 수 있습니다. +rank_permissions.2=이슈 생성/편집 할 수 있습니다. +rank_permissions.3=이슈/댓글을 삭제할 수 있습니다. +rank_permissions.4=사용자/그룹/스프린트를 생성/편집/삭제할 수 있습니다. +rank_permissions.5=환경설정을 편집 할 수 있습니다 + +; Admin Groups Tab +no_groups_exist=그룹이 없습니다. +; Groups Modal +members=구성원 +group_name=그룹 명 +group_name_saved=저장된 그룹 이름 +manager=매니저 +set_as_manager=관리자로 설정 +add_to_group=그룹 추가 +api_visible=API에 표시 + +; Admin Sprints Tab +; Sprint Modal +new_sprint=새 스프린트 +edit_sprint=스프린트 편집 +start_date=시작일 +end_date=종료일 + +; Hotkey modal +hotkeys=단축키: +hotkeys_global=전역 +show_hotkeys=단축키 표시 +reload_page=페이지 새로고침 +press_x_then_y={0} 그리고 {1} +navigation=네비게이션 +use_with_x={0} 과 함께 사용 diff --git a/app/dict/nl.ini b/app/dict/nl.ini index de386dea..f0a0efa6 100644 --- a/app/dict/nl.ini +++ b/app/dict/nl.ini @@ -3,7 +3,7 @@ log_in=Aanmelden register=Registreren username=Gebruikersnaam password=Wachtwoord -email=Email +email=E-mail email_address=Email adres reset_password=Reset Wachtwoord @@ -12,7 +12,7 @@ confirm_password=Bevestig wachtwoord cancel=Annuleren -logged_out=Afmelden +logged_out=Uitgelogd session_ended_message=Uw sessie is afgesloten. Gelieve terug aan te melden. ; Footer @@ -27,7 +27,7 @@ install_phproject=Phproject installeren ; Navbar, basic terminology new=Nieuw -Task=Task +Task=Taak Project=Project Bug=Bug sprint=Sprint @@ -37,7 +37,7 @@ open=Openen closed=Sluiten created_by_me=Aangemaakt door mij assigned_to_me=Toegewezen aan mij -by_type=By type +by_type=Op type issue_search=Snel een ticket vinden administration=Beheer dashboard=Dashboard @@ -64,27 +64,31 @@ error.loading_dependencies=Fout bij het laden van de afhankelijkheden. error.404_text=De opgevraagde pagina is niet beschikbaar. ; Dashboard -taskboard=Taskboard +taskboard=Takenbord my_projects=Mijn Projecten -subprojects=Subprojects +subprojects=Subprojecten my_subprojects=Mijn Subprojecten my_tasks=Mijn Taken bug=bug bugs=bugs my_bugs=Mijn Bugs -repeat_work=Repeat Work -my_watchlist=My Watch List -projects=Projects +repeat_work=Herhalend Werk +my_watchlist=Mijn Watch List +projects=Projecten add_project=Project toevoegen add_task=Taak toevoegen add_bug=Bug toevoegen -my_comments=My Comments -recent_comments=Recent Comments -open_comments=Open Comments -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +my_comments=Mijn reacties +recent_comments=Recente reacties +open_comments=Open Opmerkingen +add_widgets=Widgets Toevoegen +no_more_widgets=Geen widgets meer beschikbaar +no_matching_issues=Geen overeenkomende resultaten +parent_project=Bovenliggend Project + +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets ; User pages created_issues=Aangemaakte Tickets @@ -102,9 +106,11 @@ edit_on_gravatar=Aanpassen op Gravatar save=Opslaan current_password=Huidig wachtwoord profile=Profiel -settings=Settings +settings=Instellingen default=Standaardinstellingen -disable_editor=Disable Editor +disable_editor=Editor uitschakelen +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=Algemeen @@ -114,11 +120,12 @@ export=Exporteren go_previous=Vorige go_next=Volgende previous_sprints=Sprints uit het verleden -deactivated_users=Deactivated Users +deactivated_users=Gedeactiveerde Gebruikers ; Issue fields cols.id=ID cols.title=Titel +cols.size_estimate=Size Estimate cols.description=Omschrijving cols.type=Type cols.priority=Prioriteit @@ -134,33 +141,38 @@ cols.repeat_cycle=Herhalings cyclus cols.parent_id=Bovenliggend ID cols.parent=Bovenliggend cols.sprint=Sprint +cols.due_date_sprint=Set Due Date by Sprint cols.created=Aangemaakt cols.start=Start -cols.due=Due +cols.due=Opleveringsdatum cols.closed_date=Afgesloten cols.hours_spent=Uren gespendeerd cols.depends_on=Afhankelijk van ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Actief +New=Nieuw +On Hold=In de wacht +Completed=Voltooid ; Issue Priorities -High=High -Normal=Normal -Low=Low +High=Hoog +Normal=Normaal +Low=Laag ; Issue editing not_assigned=Niet toegewezen choose_option=Kies een optie +set_sprint_from_due_date=Add to sprint by due date repeating=Herhaalt zich not_repeating=Herhaalt zich niet daily=Dagelijks weekly=Weekelijks monthly=Maandelijks +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=Geen Sprint @@ -173,7 +185,7 @@ edit_n={0} aanpassen create_n={0} aanmaken save_n={0} opslaan -unsaved_changes=You have unsaved changes. +unsaved_changes=U hebt niet-opgeslagen wijzigingen. ; Issue page mark_complete=Markeer als afgewerkt @@ -181,8 +193,8 @@ complete=Afgewerkt reopen=Opnieuw openenen show_on_taskboard=Toon op takenbord copy=Kopiëren -watch=Watch -unwatch=Unwatch +watch=Bekijken +unwatch=Niet meer bekijken edit=Aanpassen delete=Verwijderen @@ -201,10 +213,10 @@ undo=Ongedaan maken comments=Opmerkingen history=Historiek watchers=Waarnemers -child_task=Child Task +child_task=Onderliggende taak child_tasks=Onderliggende taken sibling_tasks=Bovenliggende taken -related_task=Related Task +related_task=Gerelateerde Taak related_tasks=Verwante taken notifications_sent=Verzonden notificaties @@ -250,6 +262,7 @@ comment_delete_confirm=Bent u zeker dat u deze opmerking wil verwijderen? bulk_actions=Toon bulk acties bulk_update=Update alle geselecteerde taken +update_copy=Update a copy project_overview=Project overzicht project_tree=Project boom @@ -262,12 +275,13 @@ no_tags_created=Er zijn geen ticket tags aangemaakt. tag_help_1=Tag een ticket door een #hashtag toe te voegen aan de omschrijving. tag_help_2=Hashtags mogen letters, nummers en leestekens bevatten en moeten starten met een letter. view_all_tags=Bekijk alle tags -list=List +list=Lijst cloud=Cloud -count=Count +count=Aantal ; Taskboard/Backlog -backlog=Backlog +backlog=Achterstand +backlog_points=Points filter_tasks=Filter taken filter_projects=Filter projecten all_projects=Alle projecten @@ -275,18 +289,21 @@ all_tasks=Alle taken my_groups=Mijn groepen burndown=Burndown hours_remaining=Beschikbaar aantal uren -ideal_hours_remaining=Ideal Hours Remaining -daily_hours_remaining=Daily Hours Remaining +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining project=Project subproject=Subproject track_time_spent=Volg gespendeerde tijd op burn_hours=Burn uren -backlog_old_help_text=Drag projects here to remove from a sprint +backlog_old_help_text=Sleep projecten hier om van een sprint te verwijderen show_previous_sprints=Toon sprints uit het verleden show_future_sprints=Toon toekomstige sprints -unsorted_projects=Unsorted Projects +unsorted_projects=Ongesorteerde Projecten +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Versie update_available=Update beschikbaar update_to_n=Update naar {0} @@ -298,17 +315,17 @@ database=Database hostname=Hostnaam schema=Schema current_version=Huidige versie -cache=Cache -clear_cache=Clear Cache -timeouts=Timeouts -queries=Queries -minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime -max_rating=Max Rating +cache=Cachegeheugen +clear_cache=Cache Wissen +timeouts=Time-outs +queries=Query's +minify=Minimaliseren +attachments=Bijlagen +smtp_not_enabled=SMTP is niet ingeschakeld. +imap_not_enabled=IMAP is niet ingeschakeld. +miscellaneous=Overig +session_lifetime=Sessieduur +max_rating=Hoogste Waardering ; Admin Configuration Tab ; Site Basics Section @@ -319,23 +336,23 @@ timezone=Tijdzone default_theme=Standaard thema logo=Logo ; Issue Types and Statuses Section -issue_types_and_statuses=Issue Types and Statuses +issue_types_and_statuses=Issue Types en Statussen issue_types=Issue Types -issue_statuses=Issue Statuses -yes=Yes -no=No -taskboard_columns=Taskboard Columns -taskboard_sort=Taskboard Sort +issue_statuses=Issue Statussen +yes=Ja +no=Nee +taskboard_columns=Takenbord Kolommen +taskboard_sort=Takenbord Sorteren ; Text Parsing Section text_parsing=Tekst parsing -parser_syntax=Parser Syntax -advanced_options=Advanced Options (all enabled by default) +parser_syntax=Parser-Syntaxis +advanced_options=Geavanceerde opties (alle standaard ingeschakeld) convert_ids=Zet IDs om naar links convert_hashtags=Zet hashtags om naar links convert_urls=Zet URLs om naar links -convert_emoticons=Zet emoticons om naar glyphs +convert_emoticons=Convert text emoticons to Emoji ; Email Section -email_smtp_imap=Email (SMTP/IMAP) +email_smtp_imap=E-mail (SMTP/IMAP) config_note=Nota email_leave_blank=Laat dit leeg om uitgaande mail uit te schakelen. outgoing_mail=Uitgaande mail (SMTP) @@ -346,17 +363,17 @@ imap_truncate_lines=IMAP bericht afbreek lijnen (mail.truncate_lines) imap_settings_note=IMAP instellingen hebben geen effect tenzij het script {0} gestart wordt met cron. ; Advanced Section advanced=Geavanceerd -security=Security -min_pass_len=Minimum Password Length -censor_credit_card_numbers=Censor Credit Card Numbers +security=Beveiliging +min_pass_len=Minimale wachtwoordlengte +censor_credit_card_numbers=Censureer Creditcardnummers cookie_expiration=Verval tijd cookie (JAR.expire) max_upload_size=Max grootte verzonden bestanden (files.maxsize) -allow_public_registration=Allow public registration -core=Core +allow_public_registration=Openbare registratie toestaan +core=Kern debug_level=Debug niveau (DEBUG) cache_mode=Cache mode (CACHE) demo_user=Demo gebruiker (site.demo) -advanced_config_note=These values can be changed by editing the values in the {0} database table. +advanced_config_note=Deze waarden kunnen worden gewijzigd door de waardes in de {0} database te bewerken. ; Admin Users Tab new_user=Nieuwe gebruiker @@ -369,7 +386,7 @@ edit_user=Gebruiker aanpassen require_new_password=Verplicht een nieuw wachtwoord bij de volgende login user=Gebruiker administrator=Beheerder -rank=Rank +rank=Rang ranks.0=Gast ranks.1=Klient ranks.2=Gebruiker @@ -392,7 +409,7 @@ group_name_saved=Groep naam opgeslagen manager=Manager set_as_manager=Instellen als Manager add_to_group=Toevoegen aan groep -api_visible=Visible to API +api_visible=Zichtbaar voor API ; Admin Sprints Tab ; Sprint Modal diff --git a/app/dict/pl.ini b/app/dict/pl.ini new file mode 100644 index 00000000..537d6dd9 --- /dev/null +++ b/app/dict/pl.ini @@ -0,0 +1,431 @@ +; Index, login, password reset +log_in=Zaloguj się +register=Zarejestruj się +username=Nazwa użytkownika +password=Hasło +email=E-mail +email_address=Adres e-mail + +reset_password=Zresetuj hasło +new_password=Nowe hasło +confirm_password=Potwierdź hasło + +cancel=Anuluj + +logged_out=Wylogowano +session_ended_message=Twoja sesja wygasła. Zaloguj się ponownie. + +; Footer +n_queries={0,number,integer} queries +n_total_queries_n_cached=Zapytań: {0,number,integer}, zbuforowanych: {1,number,integer} +page_generated_in_n_seconds=Strona wygenerowana w {0,number} sekund(y) +real_usage_n_bytes=Użycie rzeczywiste: {0,number,integer} bajtów +current_commit_n=Aktualna wersja: {0} + +; Installer +install_phproject=Instalacja Phproject + +; Navbar, basic terminology +new=Nowy +Task=Zadanie +Project=Projekt +Bug=Błąd +sprint=Sprint +sprints=Sprint'y +browse=Przeglądaj +open=Otwarte +closed=Zamknięte +created_by_me=Stworzone przez Ciebie +assigned_to_me=Przypisane do Ciebie +by_type=Według typu +issue_search=Szybko znajdź problem +administration=Administracja +dashboard=Kokpit +issues=Problemy +my_issues=Moje problemy +my_account=Moje konto +configuration=Konfiguracja +plugins=Wtyczki +users=Użytkownicy +groups=Grupy +log_out=Wyloguj się +demo_notice=Ta strona jest uruchomiona w trybie demonstracyjnym. Wszystkie treści są publiczne i mogą zostać wyzerowane w dowolnym momencie. +loading=Wczytywanie… +close=Zamknij +related=Powiązane +current_issue=Bieżący problem +toggle_navigation=Przełącz nawigację + +; Errors +error.loading_issue_history=Błąd wczytywania historii problemu. +error.loading_issue_watchers=Błąd wczytywania obserwatorów problemu. +error.loading_related_issues=Błąd wczytywania powiązanych problemów. +error.loading_dependencies=Błąd ładowania zależności. +error.404_text=Żądana przez ciebie strona nie jest dostępna. + +; Dashboard +taskboard=Tablica zadań +my_projects=Moje projekty +subprojects=Podprojekty +my_subprojects=Moje podprojekty +my_tasks=Moje zadania +bug=błąd +bugs=błędy +my_bugs=Moje błędy +repeat_work=Powtarzana praca +my_watchlist=Obserwowane przeze mnie +projects=Projekty +add_project=Dodaj projekt +add_task=Dodaj zadanie +add_bug=Dodaj błąd +my_comments=Moje komentarze +recent_comments=Ostatnie komentarze +open_comments=Ostatnie komentarze +add_widgets=Dodaj widżety +no_more_widgets=Nie ma więcej dostępnych widżetów +no_matching_issues=Brak pasujących problemów +parent_project=Projekt nadrzędny + +manage_widgets=Zarządzaj widżetami +available_widgets=Dostępne widżety +enabled_widgets=Włączone widżety + +; User pages +created_issues=Utworzone problemy +assigned_issues=Przypisane problemy +overdue_issues=Opóźnione problemy +issue_tree=Drzewo problemu + +; Account +name=Nazwa +theme=Motyw +language=Język +task_color=Kolor zadania +avatar=Awatar +edit_on_gravatar=Edycja na Gravatar +save=Zapisz +current_password=Bieżące hasło +profile=Profil +settings=Ustawienia +default=Domyślne +disable_editor=Wyłącz edytor +disable_due_alerts=Wyłącz wiadomości o opóźnieniach problemu +disable_self_notifications=Wyłącz powiadomienia o własnych działaniach + +; Browse +general=Ogólne +exact_match=Dokładne dopasowanie +submit=Prześlij +export=Eksportuj +go_previous=Wstecz +go_next=Dalej +previous_sprints=Pokaż poprzednie Sprint'y +deactivated_users=Zdezaktywowani użytkownicy + +; Issue fields +cols.id=ID +cols.title=Tytuł +cols.size_estimate=Szacunkowy rozmiar +cols.description=Opis +cols.type=Typ +cols.priority=Priotytet +cols.status=Stan +cols.author=Autor +cols.assignee=Przypisane do +cols.total_spent_hours=Suma spędzonych godzin +cols.hours_total=Planowane godziny +cols.hours_remaining=Pozostałe godziny +cols.start_date=Data rozpoczęcia +cols.due_date=Termin wymagalności +cols.repeat_cycle=Cykl powtarzania +cols.parent_id=ID nadrzędnej sprawy +cols.parent=Nadrzędny +cols.sprint=Sprint +cols.due_date_sprint=Ustaw czas zakończenia wg Sprintu +cols.created=Utworzono +cols.start=Rozpoczęcie +cols.due=Wymagalność +cols.closed_date=Zamknięty +cols.hours_spent=Spędzone godziny +cols.depends_on=Zależny od + +; Issue Statuses +Active=Aktywny +New=Nowy +On Hold=Zawieszony +Completed=Zakończony + +; Issue Priorities +High=Wysoki +Normal=Zwykły +Low=Niski + +; Issue editing +not_assigned=Nieprzypisany +choose_option=Wybierz opcję +set_sprint_from_due_date=Dodaj do Sprintu wg daty zakończenia + +repeating=Powtarzający się +not_repeating=Niepowtarzający się +daily=Codziennie +weekly=Co tydzień +monthly=Co miesiąc +quarterly=Co kwartał +semi_annually=Co pół roku +annually=Co rok + +no_sprint=Brak Sprintu + +comment=Komentarz + +send_notifications=Wyślij powiadomienia +reset=Przywróć +new_n=Nowy {0} +edit_n=Edytuj {0} +create_n=Utwórz {0} +save_n=Zapisz {0} + +unsaved_changes=Masz niezapisane zmiany. + +; Issue page +mark_complete=Oznacz jako ukończone +complete=Ukończono +reopen=Otwórz ponownie +show_on_taskboard=Pokaż na tablicy zadań +copy=Kopiuj +watch=Obserwuj +unwatch=Przestań obserwować +edit=Edytuj +delete=Usuń + +files=Pliki +upload=Wyślij na serwer +upload_a_file=Prześlij plik +attached_file=Załączony plik +deleted=Usunięto +file_name=Nazwa pliku +uploaded_by=Dodane przez +upload_date=Data przesłania +file_size=Rozmiar pliku +file_deleted=Plik usunięty. +undo=Cofnij + +comments=Kometarze +history=Historia +watchers=Obserwujący +child_task=Zadanie podrzędne +child_tasks=Zadania podrzędne +sibling_tasks=Zadania pokrewne +related_task=Powiązane zadanie +related_tasks=Powiązane zadania + +notifications_sent=Powiadomienia wysłane +notifications_not_sent=Powiadomienia nie wysłane +a_changed={0} zmienione: +a_changed_from_b_to_c={0} zmienione z {1} na {2} +a_set_to_b={0} ustawione jako {1} +a_removed={0} usunięte + +dependencies=Zależności +dependency=Zależność +dependent=Zależny +task_depends=To zadanie zależy od: +add_dependency=Dodaj zależności +task_dependency=To zadanie jest zależnością dla: +add_dependent=Dodaj uzależnione + +; Dependency Types (Finish-Start, Finish-Finish, Start-Start, Start-Finish) +fs=KP +ff=KK +ss=PP +sf=PK + +write_a_comment=Napisz komentarz… +save_comment=Zapisz komentarz + +no_history_available=Historia nie jest dostępna +add_watcher=Dodaj obserwatora + +new_sub_project=Nowy podprojekt +new_task=Nowe zadanie +under_n=Pod {0} +no_related_issues=Brak powiązanych problemów + +copy_issue=Skopiuj problem +copy_issue_details=Skopiowanie tego problemu spowoduje utworzenie duplikatu problemu i wszystkich jej potomków. Następujące elementy nie zostaną skopiowane: komentarze, pliki, historia, obserwatorzy. + +deleted_success="Problem #{0} usunięty pomyślnie." +deleted_notice=Ten problem został usunięty. Jego edycja nadal spowoduje wysłanie powiadomień, ale odbiorcy nie będą w stanie go zobaczyć, o ile nie są administratorami. +restore_issue=Przywróć problem + +comment_delete_confirm=Czy na pewno chcesz usunąć ten komentarz? + +bulk_actions=Pokaż akcje masowe +bulk_update=Zaktualizuj wszystkie zaznaczone zadania +update_copy=Zaktualizuj kopię + +project_overview=Przegląd projektu +project_tree=Drzewo projektu +n_complete=Ukończono {0} +n_child_issues=Problemy podrzędne: {0} +project_no_files_attached=Żadne pliki nie są dołączone do problemów w tym projekcie. + +; Tags +issue_tags=Znaczniki problemu +no_tags_created=Nie utworzono jeszcze żadnych znaczników. +tag_help_1="Oznacz problem przez dodanie #hasztaga do jego opisu." +tag_help_2=Hasztagi mogą zawierać litery, cyfry i myślniki oraz muszą zaczynać się od litery. +view_all_tags=Zobacz wszystkie znaczniki +list=Lista +cloud=Chmura +count=Ilość + +; Taskboard/Backlog +backlog=Zaległości +backlog_points=Punkty +filter_tasks=Filtruj zadania +filter_projects=Filtruj projekty +all_projects=Wszystkie projekty +all_tasks=Wszystkie zadania +my_groups=Moje grupy +burndown=Wykonanie +hours_remaining=Pozostałe godziny +man_hours=Roboczogodziny +man_hours_remaining=Pozostałe roboczogodziny +project=Projekt +subproject=Podprojekt +track_time_spent=Śledzenie spędzonego czasu +burn_hours=Wykorzystaj godziny +backlog_old_help_text=Przeciągnij projekt tutaj, aby usunąć ze Sprint'u +show_previous_sprints=Pokaż poprzednie Sprint'y +show_future_sprints=Pokaż przyszłe Sprint'y +unsorted_projects=Nieposortowane projekty +unsorted_items=Nieposortowane elementy + +; Administration +release_available=Dostępna jest nowa wersja! +releases=Wyświetl wersje +version=Wersja +update_available=Dostępna aktualizacja +update_to_n=Aktualizacja do {0} +backup_db=Jesteś pewien?\nPowinieneś wykonać kopię zapasową bazy danych przed kontynuowaniem. + +; Admin Overview Tab +overview=Podsumowanie +database=Baza danych +hostname=Nazwa hosta +mailbox=Skrzynka pocztowa +schema=Baza +current_version=Aktualna wersja +cache=Pamięć podręczna +clear_cache=Wyczyść +timeouts=Limity czasu +queries=Zapytania +minify=Minimalizacja +attachments=Załączniki +smtp_not_enabled=SMTP nie jest włączony. +imap_not_enabled=IMAP nie jest włączony. +miscellaneous=Różne +session_lifetime=Czas życia sesji +max_rating=Maksymalna ocena +mailbox_help=Nie wiesz, czego użyć? Zajrzyj do dokumentacji. + +; Admin Configuration Tab +; Site Basics Section +site_basics=Podstawy strony +site_name=Nazwa witryny +site_description=Opis witryny +timezone=Strefa czasowa +default_theme=Domyślny motyw +logo=Logo +; Issue Types and Statuses Section +issue_types_and_statuses=Typy i statusy problemów +issue_types=Typy problemów +issue_statuses=Statusy problemów +yes=Tak +no=Nie +taskboard_columns=Kolumny panelu zadań +taskboard_sort=Sortowanie panelu zadań +; Text Parsing Section +text_parsing=Analiza tekstu +parser_syntax=Analizator składni +advanced_options=Zaawansowane opcje (wszystkie domyślnie włączone) +convert_ids=Konwertuj identyfikatory na odnośniki +convert_hashtags=Konwertuj hasztagi na odnośniki +convert_urls=Konwertuj adresy URL na odnośniki +convert_emoticons=Zmieniaj emotikony tekstowe na Emoji +; Email Section +email_smtp_imap=E-mail (SMTP/IMAP) +config_note=Uwaga +email_leave_blank=Pozostaw puste, aby wyłączyć wychodzące wiadomości e-mail +outgoing_mail=Poczta wychodząca (SMTP) +from_address=Adres "Od" +package_mail_config_note={0} wykorzystuje domyślną konfigurację poczty PHP dla wychodzących wiadomości e-mail. +incoming_mail=Poczta przychodząca (IMAP) +imap_truncate_lines=Obcięcie wierszy wiadomości IMAP +imap_settings_note=Ustawienia IMAP tutaj nie będą miały żadnych efektów, chyba że uruchomiono zadanie cron'a {0}. +; Advanced Section +advanced=Zaawansowane +security=Bezpieczeństwo +min_pass_len=Minimalna długość hasła +censor_credit_card_numbers=Ocenzuruj numery kart kredytowych +cookie_expiration=Czas ważności cookie (sekundy) +max_upload_size=Maksymalny rozmiar wysyłanego pliku (bajty) +allow_public_registration=Pozwól na publiczną rejestrację +core=Rdzeń +debug_level=Poziom debugowania +cache_mode=Tryb pamięci podręcznej +demo_user=Użytkownik demonstracyjny (site.demo) +advanced_config_note=Wartości te mogą być zmienione poprzez zmianę wartości w tabeli {0} w bazie danych. + +; Admin Users Tab +new_user=Nowy użytkownik +deactivate=Dezaktywuj +reactivate=Reaktywuj +role=Rola +show_deactivated_users=Pokaż zdezaktywowanych użytkowników +; User Modal +edit_user=Edytuj użytkownika +require_new_password=Wymagaj nowego hasła przy następnym logowaniu +user=Użytkownik +administrator=Administrator +rank=Ranga +ranks.0=Gość +ranks.1=Klient +ranks.2=Użytkownik +ranks.3=Menedżer +ranks.4=Administrator +ranks.5=Super Administrator +rank_permissions.0=Tylko do odczytu +rank_permissions.1=Może dodawać komentarze +rank_permissions.2=Może tworzyć/edytować problemy +rank_permissions.3=Może usuwać sprawy/komentarze +rank_permissions.4=Może tworzyć/edytować/usuwać użytkowników/grupy/Sprint'y +rank_permissions.5=Może edytować konfigurację + +; Admin Groups Tab +no_groups_exist=Nie istnieją żadne grupy. +; Groups Modal +members=Członkowie +group_name=Nazwa grupy +group_name_saved=Zapisano nazwę grupy +manager=Menedżer +set_as_manager=Ustaw jako menedżera +add_to_group=Dodaj do grupy +api_visible=Widoczne dla interfejsu API + +; Admin Sprints Tab +; Sprint Modal +new_sprint=Nowy Sprint +edit_sprint=Edytuj Sprint +start_date=Data rozpoczęcia +end_date=Data zakończenia + +; Hotkey modal +hotkeys=Skróty klawiaturowe +hotkeys_global=Globalne +show_hotkeys=Pokaż skróty klawiaturowe +reload_page=Przeładuj stronę +press_x_then_y={0}, a następnie {1} +navigation=Nawigacja +use_with_x=Użyj z {0} diff --git a/app/dict/pt.ini b/app/dict/pt.ini index 6c1a3053..401931ae 100644 --- a/app/dict/pt.ini +++ b/app/dict/pt.ini @@ -27,8 +27,8 @@ install_phproject=Instale Phproject ; Navbar, basic terminology new=Novo -Task=Task -Project=Project +Task=Tarefa +Project=Projeto Bug=Bug sprint=Sprint sprints=Sprints @@ -46,7 +46,7 @@ my_issues=Meus Problemas my_account=Minha Conta configuration=Configuração plugins=Plugins -users=Usuários +users=Utilizadores groups=Grupos log_out=Logout demo_notice=Este site está sendo executado em modo de demonstração. Todo o conteúdo é público e pode ser reajustado a qualquer momento. @@ -57,7 +57,7 @@ current_issue=Problema atual toggle_navigation=Navegação toggle ; Errors -error.loading_issue_history=Erro ao carregar a história problema. +error.loading_issue_history=Erro ao carregar o histórico do problema. error.loading_issue_watchers=Erro ao carregar a problema observadores. error.loading_related_issues=Erro ao carregar os problemas relacionados. error.loading_dependencies=Erro ao carregar as dependências. @@ -66,7 +66,7 @@ error.404_text=A página que você solicitou não está disponível. ; Dashboard taskboard=Quadro de Tarefas my_projects=Meus Projetos -subprojects=Subprojects +subprojects=Subprojeto my_subprojects=Meus subprojetos my_tasks=Minhas Tarefas bug=bug @@ -74,17 +74,21 @@ bugs=bugs my_bugs=Meus Erros repeat_work=Trabalho repetição my_watchlist=Minha lista de observação -projects=Projects +projects=Projetos add_project=Adicionar Projeto add_task=Adicionar Tarefa add_bug=Adicionar Erro my_comments=Meus Comentários recent_comments=Comentários Recentes open_comments=Comentários Abertos -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +add_widgets=Adicionar Widgets +no_more_widgets=Sem outros widgets disponíveis +no_matching_issues=Sem questões correspondentes +parent_project=Projeto Pai + +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets ; User pages created_issues=Problemas Criados @@ -105,6 +109,8 @@ profile=Informação Pessoal settings=Configurações default=Padrão disable_editor=Desativar o Editor +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=Geral @@ -114,11 +120,12 @@ export=Exportar go_previous=Anterior go_next=Seguinte previous_sprints=Anteriores sprints -deactivated_users=Deactivated Users +deactivated_users=Desativar usuários ; Issue fields cols.id=ID cols.title=Título +cols.size_estimate=Size Estimate cols.description=Descrição cols.type=Tipo cols.priority=Prioridade @@ -134,6 +141,7 @@ cols.repeat_cycle=Ciclo de repetição cols.parent_id=ID de parente cols.parent=Parente cols.sprint=Sprint +cols.due_date_sprint=Set Due Date by Sprint cols.created=Criação cols.start=Início cols.due=Vencimento @@ -142,25 +150,29 @@ cols.hours_spent=Horas gastas cols.depends_on=Depende ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Ativar +New=Novo +On Hold=Em espera +Completed=Completo ; Issue Priorities -High=High +High=Alto Normal=Normal -Low=Low +Low=Baixo ; Issue editing not_assigned=Não atribuído choose_option=Eescolha uma opção +set_sprint_from_due_date=Add to sprint by due date repeating=Repetindo not_repeating=Não repetir daily=Diariamente weekly=Semanalmente monthly=Mensalmente +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=Não Sprint @@ -173,7 +185,7 @@ edit_n=Editar {0} create_n=Criar {0} save_n=Salvar {0} -unsaved_changes=You have unsaved changes. +unsaved_changes=Você possui mudanças não salvas. ; Issue page mark_complete=Concluída @@ -201,10 +213,10 @@ undo=Desfazer comments=Comentários history=Hiistória watchers=Observadores -child_task=Child Task +child_task=Tarefa filha child_tasks=Tarefas filho sibling_tasks=Irmão Tarefas -related_task=Related Task +related_task=Tarefa Relacionada related_tasks=Tarefas relacionadas notifications_sent=Notificações enviadas @@ -250,6 +262,7 @@ comment_delete_confirm=Tem certeza de que deseja excluir este comentário? bulk_actions=Mostrar ações em massa bulk_update=Atualizar estes problemas +update_copy=Update a copy project_overview=Visão geral do projeto project_tree=Projeto árvore @@ -268,6 +281,7 @@ count=Contagem ; Taskboard/Backlog backlog=Lista de pendências +backlog_points=Points filter_tasks=Filtrar tarefas filter_projects=Filtrar projetos all_projects=Todos os projetos @@ -275,8 +289,8 @@ all_tasks=Todas as tarefas my_groups=Meus grupos burndown=Burndown hours_remaining=Horas restantes -ideal_hours_remaining=Horas ideais restantes -daily_hours_remaining=Horas diárias restantes +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining project=Projeto subproject=Subprojeto track_time_spent=Controlar o tempo gasto @@ -284,9 +298,12 @@ burn_hours=Queimar horas backlog_old_help_text=Arraste projetos aqui para remover de um sprint show_previous_sprints=Mostrar anteriores sprints show_future_sprints=Mostrar o futuros sprints -unsorted_projects=Unsorted Projects +unsorted_projects=Projeto Desordenados +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Versão update_available=Atualização disponível update_to_n=Actualização de {0} @@ -294,21 +311,21 @@ backup_db=Você tem certeza?\nVocê deve fazer backup de seu banco de dados ante ; Admin Overview Tab overview=Visão Global -database=Database +database=Banco de Dados hostname=Nome do host -schema=Schema +schema=Esquema current_version=Versão atual cache=Cache -clear_cache=Clear Cache -timeouts=Timeouts -queries=Queries -minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime -max_rating=Max Rating +clear_cache=Limpar Cache +timeouts=Tempo Limite +queries=Consultas +minify=Minimizar +attachments=Anexos +smtp_not_enabled=SMTP não esta habilitado. +imap_not_enabled=IMAP não está habilitado. +miscellaneous=Miscelânea +session_lifetime=Tempo de vida da sessão +max_rating=Avaliação Máxima ; Admin Configuration Tab ; Site Basics Section @@ -319,13 +336,13 @@ timezone=Fuso horário default_theme=Tema Padrão logo=Logo ; Issue Types and Statuses Section -issue_types_and_statuses=Issue Types and Statuses -issue_types=Issue Types -issue_statuses=Issue Statuses -yes=Yes -no=No -taskboard_columns=Taskboard Columns -taskboard_sort=Taskboard Sort +issue_types_and_statuses=Tipos e Status das Questões +issue_types=Tipos de Questões +issue_statuses=Status das Questões +yes=Sim +no=Não +taskboard_columns=Colunas do Quadro de Tarefas +taskboard_sort=Ordenar Quadro de Tarefas ; Text Parsing Section text_parsing=Análise de texto parser_syntax=Sintaxe de analisador @@ -333,7 +350,7 @@ advanced_options=Opções avançadas (todos habilitado por padrão) convert_ids=Converter IDs para Links convert_hashtags=Converter Hashtags para Links convert_urls=Converter URLs para links -convert_emoticons=Converter emoticons para glifos +convert_emoticons=Convert text emoticons to Emoji ; Email Section email_smtp_imap=E-mail (SMTP/IMAP) config_note=Nota @@ -392,7 +409,7 @@ group_name_saved=Nome do grupo foi salvo manager=Diretor set_as_manager=Definir como diretor add_to_group=Adicionar ao grupo -api_visible=Visible to API +api_visible=Visível para o API ; Admin Sprints Tab ; Sprint Modal diff --git a/app/dict/ru.ini b/app/dict/ru.ini index 19b11d80..29755fbb 100644 --- a/app/dict/ru.ini +++ b/app/dict/ru.ini @@ -17,19 +17,19 @@ session_ended_message=Ваша сессия истекла. Пожалуйста ; Footer n_queries={0,number,integer} queries -n_total_queries_n_cached={0,number,integer} total queries, {1,number,integer} cached -page_generated_in_n_seconds=Page generated in {0,number} seconds -real_usage_n_bytes=Real usage: {0,number,integer} bytes -current_commit_n=Current commit: {0} +n_total_queries_n_cached=суммарно запросов: {0,number,integer}, {1,number,integer} закэшировано +page_generated_in_n_seconds=Страница сгенерирована за {0,number} секунд +real_usage_n_bytes=Использовано: {0,number,integer} байт +current_commit_n=Текущий коммит: {0} ; Installer install_phproject=Установка Phproject ; Navbar, basic terminology new=Новая -Task=Task -Project=Project -Bug=Bug +Task=Задача +Project=Проект +Bug=Баг sprint=Спринт sprints=Спринты browse=Просмотр @@ -37,7 +37,7 @@ open=Открыт closed=Закрыт created_by_me=Созданные мной assigned_to_me=Назначеные мне -by_type=By type +by_type=По типу issue_search=Быстрый поиск administration=Администратор dashboard=Панель @@ -52,9 +52,9 @@ log_out=Выход demo_notice=Сайт работает в демонстрационном режиме. Все содержимое является публичным и может быть сброшено в любое время. loading=Загрузка… close=Закрыть -related=Related +related=Связанные current_issue=Текущая задача -toggle_navigation=Toggle Navigation +toggle_navigation=Переключить навигацию ; Errors error.loading_issue_history=Ошибка загрузки истории задачи. @@ -66,25 +66,29 @@ error.404_text=Запрошенная страница отсутствует. ; Dashboard taskboard=Панель задач my_projects=Мои проекты -subprojects=Subprojects +subprojects=Подпроекты my_subprojects=Мои подпроекты my_tasks=Мои задачи -bug=bug -bugs=bugs +bug=баг +bugs=баги my_bugs=Мои баги -repeat_work=Repeat Work -my_watchlist=My Watch List -projects=Projects +repeat_work=Повторяющиеся задачи +my_watchlist=Мой список наблюдения +projects=Проекты add_project=Добавить проект add_task=Добавить Задачу add_bug=Добавить Проблему my_comments=Мои комментарии -recent_comments=Recent Comments -open_comments=Open Comments -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +recent_comments=Последние комментарии +open_comments=Открытые комментарии +add_widgets=Добавить виджеты +no_more_widgets=Больше нет доступных виджетов +no_matching_issues=Нет соответствующих задач +parent_project=Родительский проект + +manage_widgets=Manage Widgets +available_widgets=Available Widgets +enabled_widgets=Enabled Widgets ; User pages created_issues=Созданные задачи @@ -105,6 +109,8 @@ profile=Профиль settings=Настройки default=По умолчанию disable_editor=Отключить редактор +disable_due_alerts=Disable Due Issue Emails +disable_self_notifications=Disable notifications for own actions ; Browse general=Общее @@ -113,12 +119,13 @@ submit=Отправить export=Экспорт go_previous=Предыдущая go_next=Следующая -previous_sprints=Previous Sprints -deactivated_users=Deactivated Users +previous_sprints=Предыдущий спринт +deactivated_users=Деактивированные пользователи ; Issue fields cols.id=ID cols.title=Название +cols.size_estimate=Size Estimate cols.description=Описание cols.type=Тип cols.priority=Приоритет @@ -134,33 +141,38 @@ cols.repeat_cycle=Цикл повтора cols.parent_id=Родительский ID cols.parent=Родитель cols.sprint=Спринт +cols.due_date_sprint=Set Due Date by Sprint cols.created=Создан -cols.start=Start +cols.start=Начат cols.due=Срок cols.closed_date=Закрыт cols.hours_spent=Часов затрачено -cols.depends_on=Depends On +cols.depends_on=Зависит от ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=Активна +New=Новая +On Hold=На удержании +Completed=Выполнено ; Issue Priorities -High=High -Normal=Normal -Low=Low +High=Выоский +Normal=Нормальный +Low=Низкий ; Issue editing not_assigned=Не распределен choose_option=Выберите опции +set_sprint_from_due_date=Add to sprint by due date -repeating=Repeating +repeating=Повторять not_repeating=Без повтора daily=Ежедневно weekly=Еженедельно monthly=Ежемесячно +quarterly=Quarterly +semi_annually=Semi-annually +annually=Annually no_sprint=Нет спринтов @@ -173,7 +185,7 @@ edit_n=Изменено {0} create_n=Создать {0} save_n=Сохранить {0} -unsaved_changes=You have unsaved changes. +unsaved_changes=У Вас есть несохраненные изменения. ; Issue page mark_complete=Отмечено завершенным @@ -191,28 +203,28 @@ upload=Загрузка upload_a_file=Загрузить Файл attached_file=Приложить Файл deleted=Удалено -file_name=File Name -uploaded_by=Uploaded By -upload_date=Upload Date -file_size=File Size -file_deleted=File deleted. -undo=Undo +file_name=Имя файла +uploaded_by=Загружено +upload_date=Дата загрузки +file_size=Размер файла +file_deleted=Файл удален +undo=Отменить comments=Комментарии history=История watchers=Наблюдатели -child_task=Child Task +child_task=Дочерняя задача child_tasks=Дочерние задачи -sibling_tasks=Sibling Tasks -related_task=Related Task +sibling_tasks=Соседние задачи +related_task=Связанная задача related_tasks=Связаные задачи -notifications_sent=Notifications sent -notifications_not_sent=Notifications not sent -a_changed={0} changed: -a_changed_from_b_to_c={0} changed from {1} to {2} -a_set_to_b={0} set to {1} -a_removed={0} removed +notifications_sent=Уведомление отправлено +notifications_not_sent=Уведомление не отправлено +a_changed={0} изменено: +a_changed_from_b_to_c={0} изменено с {1} на {2} +a_set_to_b={0} изменено на {1} +a_removed={0} удалено dependencies=Зависимости dependency=Зависимость @@ -250,11 +262,12 @@ comment_delete_confirm=Вы точно хотите удалить этот ко bulk_actions=Показать все действия bulk_update=Обновить все выбранные задачи +update_copy=Update a copy project_overview=Обзор проекта project_tree=Дерево проекта n_complete={0} завершено -n_child_issues={0} child issues +n_child_issues={0} дочерних задач ; Tags issue_tags=Теги задач @@ -262,52 +275,56 @@ no_tags_created=Теги пока еще не созданы. tag_help_1=Для создания тега задачи добавьте #хештег в ее описание. tag_help_2=Хештеги могут содержать буквы, числа, и дефисы, а также должны начинаться с буквы. view_all_tags=Все теги -list=List -cloud=Cloud -count=Count +list=Список +cloud=Облако +count=Всего ; Taskboard/Backlog backlog=Backlog -filter_tasks=Filter Tasks -filter_projects=Filter Projects -all_projects=All Projects -all_tasks=All Tasks -my_groups=My Groups +backlog_points=Points +filter_tasks=Фильтровать задачи +filter_projects=Фильтровать проекты +all_projects=Все проекты +all_tasks=Все задачи +my_groups=Мои группы burndown=Burndown hours_remaining=Осталось Часов -ideal_hours_remaining=Идеально оставшееся время -daily_hours_remaining=Ежедневно осталось часов -project=Project -subproject=Subproject -track_time_spent=Track Time Spent +man_hours=Man Hours +man_hours_remaining=Man Hours Remaining +project=Проект +subproject=Подпроект +track_time_spent=Времени затрачено burn_hours=Burn hours -backlog_old_help_text=Drag projects here to remove from a sprint -show_previous_sprints=Show Previous Sprints -show_future_sprints=Show Future Sprints -unsorted_projects=Unsorted Projects +backlog_old_help_text=Перетащите проекты сюда, чтобы убрать их из спринта +show_previous_sprints=Показать предыдущие спринты +show_future_sprints=Показать будущие спринты +unsorted_projects=Несортированные проекты +unsorted_items=Unsorted Items ; Administration +release_available=A new release is available! +releases=View Releases version=Версия -update_available=Update Available -update_to_n=Update to {0} -backup_db=Are you sure?\nYou should back up your database before you proceed. +update_available=Доступно обновление +update_to_n=Обновить до {0} +backup_db=Вы уверены?\nСделайте резервную копию базы данных прежде, чем продолжать. ; Admin Overview Tab overview=Обзор -database=Database +database=База данных hostname=Hostname -schema=Schema +schema=Схема current_version=Текущая версия -cache=Cache -clear_cache=Clear Cache -timeouts=Timeouts -queries=Queries -minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime +cache=Кэш +clear_cache=Очистить кэш +timeouts=Тайм-ауты +queries=Запросов +minify=Минифицировать +attachments=Прикрепленные документы +smtp_not_enabled=SMTP недоступен. +imap_not_enabled=IMAP недоступен. +miscellaneous=Разное +session_lifetime=Время жизни сессии max_rating=Max Rating ; Admin Configuration Tab @@ -319,44 +336,44 @@ timezone=Часовой пояс default_theme=Тема по умолчанию logo=Логотип ; Issue Types and Statuses Section -issue_types_and_statuses=Issue Types and Statuses -issue_types=Issue Types -issue_statuses=Issue Statuses -yes=Yes -no=No -taskboard_columns=Taskboard Columns -taskboard_sort=Taskboard Sort +issue_types_and_statuses=Виды и статусы задач +issue_types=Виды задач +issue_statuses=Статусы задач +yes=Да +no=Нет +taskboard_columns=Колонки на панели задач +taskboard_sort=Сортировка на панели задач ; Text Parsing Section text_parsing=Парсинг текста parser_syntax=Парсер синтаксиса advanced_options=Дополнительные параметры (по умолчанию все включены) convert_ids=Конвертировать ID в ссылки -convert_hashtags=Конвертировать Хештеги в ссылки +convert_hashtags=Конвертировать хэштеги в ссылки convert_urls=Конвертировать URL в ссылки -convert_emoticons=Convert emoticons to glyphs +convert_emoticons=Convert text emoticons to Emoji ; Email Section email_smtp_imap=Email (SMTP/IMAP) -config_note=Note -email_leave_blank=Leave blank to disable outgoing email +config_note=Заметьте: +email_leave_blank=Оставьте пустым, чтобы не использовать исходящую почту outgoing_mail=Исходящая почта (SMTP) from_address=Адрес от кого -package_mail_config_note={0} uses your default PHP mail configuration for outgoing email. +package_mail_config_note={0} использует Ваши настройки PHP для исходящей почты. incoming_mail=Входящая почта (IMAP) imap_truncate_lines=IMAP Message Truncate Lines (mail.truncate_lines) -imap_settings_note=IMAP settings here will have no effect unless the {0} cron is being run. +imap_settings_note=настройки IMAP не будут иметь никакого эффекта до тех пор, пока {0} запущен в cron'e. ; Advanced Section advanced=Прочее -security=Security -min_pass_len=Minimum Password Length +security=Безопасность +min_pass_len=Минимальная длина пароля censor_credit_card_numbers=Censor Credit Card Numbers (security.block_ccs) cookie_expiration=Cookie Expiration (JAR.expire) -max_upload_size=Max Upload Size (files.maxsize) -allow_public_registration=Allow public registration -core=Core +max_upload_size=Максимальный размер загружаемого файла (files.maxsize) +allow_public_registration=Разрешить свободную регистрацию +core=Ядро debug_level=Debug Level (DEBUG) cache_mode=Cache Mode (CACHE) demo_user=Demo User (site.demo) -advanced_config_note=These values can be changed by editing the values in the {0} database table. +advanced_config_note=Эти параметры могут быть изменены, при редактировании значений в таблице базы данных: {0}. ; Admin Users Tab new_user=Новый пользователь @@ -365,23 +382,23 @@ reactivate=Восстановить role=Роль show_deactivated_users=Показать отключенных пользователей ; User Modal -edit_user=Редактирование пользователя +edit_user=Редактировать пользователя require_new_password=Требовать новый пароль при следующем входе user=Пользователь administrator=Администратор -rank=Rank -ranks.0=Guest -ranks.1=Client -ranks.2=User -ranks.3=Manager -ranks.4=Admin -ranks.5=Super Admin -rank_permissions.0=Read-only -rank_permissions.1=Can post comments -rank_permissions.2=Can create/edit issues -rank_permissions.3=Can delete issues/comments -rank_permissions.4=Can create/edit/delete users/groups/sprints -rank_permissions.5=Can edit configuration +rank=Статус пользователя +ranks.0=Гость +ranks.1=Клиент +ranks.2=Пользователь +ranks.3=Менеджер +ranks.4=Администратор +ranks.5=Супер администратор +rank_permissions.0=Может только просматривать +rank_permissions.1=Может только комментировать +rank_permissions.2=Может создавать и редактировать задачи +rank_permissions.3=Может удалять задачи и комментарии +rank_permissions.4=Может создавать/редактировать/удалять пользователей/группы/спринты +rank_permissions.5=Может изменять настройки ; Admin Groups Tab no_groups_exist=Группы еще не созданы @@ -392,7 +409,7 @@ group_name_saved=Название группы сохранено manager=Менеджер set_as_manager=Назначить менеджером add_to_group=Добавить в группу -api_visible=Visible to API +api_visible=Видимость для API ; Admin Sprints Tab ; Sprint Modal diff --git a/app/dict/zh.ini b/app/dict/zh.ini index 1352e50e..17426ee8 100644 --- a/app/dict/zh.ini +++ b/app/dict/zh.ini @@ -27,9 +27,9 @@ install_phproject=安装 Phproject ; Navbar, basic terminology new=新建 -Task=Task -Project=Project -Bug=Bug +Task=任务 +Project=專案 +Bug=错误 sprint=冲刺 sprints=冲刺 browse=浏览 @@ -37,7 +37,7 @@ open=开放 closed=已关闭 created_by_me=由我创建 assigned_to_me=分配给我 -by_type=By type +by_type=按类型 issue_search=快速查找问题 administration=行政管理 dashboard=信息中心 @@ -66,25 +66,29 @@ error.404_text=您请求的页面不可用。 ; Dashboard taskboard=任务板 my_projects=我的项目 -subprojects=Subprojects +subprojects=子项目 my_subprojects=我的子项目 my_tasks=我的任务 -bug=bug -bugs=bugs +bug=错误 +bugs=错误 my_bugs=我的 Bug repeat_work=重复工作 my_watchlist=我的观察名单 -projects=Projects +projects=專案 add_project=添加项目 add_task=添加任务 add_bug=添加 Bug my_comments=我的评论 recent_comments=最近评论 -open_comments=Open Comments -add_widgets=Add Widgets -no_more_widgets=No more widgets available -no_matching_issues=No matching issues -parent_project=Parent Project +open_comments=最近评论 +add_widgets=新增小工具 +no_more_widgets=沒有可用的工具 +no_matching_issues=没有符合条件的事件 +parent_project=上一層專案 + +manage_widgets=管理小工具 +available_widgets=可用的小工具 +enabled_widgets=启用的小工具 ; User pages created_issues=以创建工单 @@ -105,6 +109,8 @@ profile=个人信息 settings=设置 default=默认​​​​​ disable_editor=禁用编辑器 +disable_due_alerts=取消到期事件信件通知 +disable_self_notifications=取消自己的行動的通知 ; Browse general=通用 @@ -114,11 +120,12 @@ export=导出 go_previous=上一项 go_next=下一个 previous_sprints=显示以前的冲刺 -deactivated_users=Deactivated Users +deactivated_users=停用的用户 ; Issue fields cols.id=ID cols.title=标题​​​ +cols.size_estimate=估計大小 cols.description=描述 cols.type=类型 cols.priority=优先级 @@ -134,6 +141,7 @@ cols.repeat_cycle=重复周期 cols.parent_id=上级 ID cols.parent=上级 cols.sprint=冲刺 +cols.due_date_sprint=按Sprint设置到期日期 cols.created=已创建 cols.start=开始 cols.due=截止 @@ -142,25 +150,29 @@ cols.hours_spent=已花费工时 cols.depends_on=依赖于 ; Issue Statuses -Active=Active -New=New -On Hold=On Hold -Completed=Completed +Active=开启的 +New=新增 +On Hold=等待审核 +Completed=已完成 ; Issue Priorities -High=High -Normal=Normal -Low=Low +High=高 +Normal=一般 +Low=低 ; Issue editing not_assigned=未分配 choose_option=选择一个选项 +set_sprint_from_due_date=按到期日期添加到sprit repeating=重复 not_repeating=不重复 daily=每日 weekly=每周 monthly=每月 +quarterly=每季一次 +semi_annually=半年一次 +annually=每年 no_sprint=没有冲刺 @@ -173,7 +185,7 @@ edit_n=编辑 {0} create_n=创建 {0} save_n=保存 {0} -unsaved_changes=You have unsaved changes. +unsaved_changes=您有未保存的更改。 ; Issue page mark_complete=标记为已完成 @@ -201,10 +213,10 @@ undo=撤消 comments=评论 history=历史记录 watchers=观察员 -child_task=Child Task +child_task=子任务 child_tasks=子任务 sibling_tasks=同级任务 -related_task=Related Task +related_task=关联任务 related_tasks=关联任务 notifications_sent=已发出的通知 @@ -242,7 +254,7 @@ no_related_issues=无关联工单 copy_issue=复制工单 copy_issue_details=复制此工单将复制所有其子项。但不会复制评论、文件、历史记录或观察员。 -deleted_success=工单 #{0} 已成功删除。 +deleted_success="工单 #{0} 已成功删除。" deleted_notice=此工单已被删除。 对其编辑仍会发送通知,但除管理员以外的收件人将无法查看此工单。 restore_issue=恢复工单 @@ -250,6 +262,7 @@ comment_delete_confirm=您确定要删除该评论? bulk_actions=显示批量操作 bulk_update=更新所有选定的任务 +update_copy=更新副本 project_overview=项目概述 project_tree=项目树 @@ -259,15 +272,16 @@ n_child_issues={0} 子工单 ; Tags issue_tags=工单标签 no_tags_created=尚无工单标签被创建。 -tag_help_1=通过将 #标签 添加到工单描述中来创建标签。 -tag_help_2=#标签 可以包含字母、 数字和连字符,并且必须以字母开头。 +tag_help_1="通过将 #标签 添加到工单描述中来创建标签。" +tag_help_2="#标签 可以包含字母、 数字和连字符,并且必须以字母开头。" view_all_tags=查看所有标签 -list=List -cloud=Cloud -count=Count +list=列表 +cloud=云 +count=计数 ; Taskboard/Backlog backlog=积压工单 +backlog_points=点 filter_tasks=筛选任务 filter_projects=筛选项目 all_projects=所有项目 @@ -275,8 +289,8 @@ all_tasks=所有任务 my_groups=我的群组 burndown=燃尽 hours_remaining=剩余工时 -ideal_hours_remaining=理想剩余工时 -daily_hours_remaining=每天剩余工时 +man_hours=人工小时 +man_hours_remaining=剩余工时 project=项目 subproject=子项目 track_time_spent=跟踪时间 @@ -284,9 +298,12 @@ burn_hours=燃烧工时 backlog_old_help_text=拖移项目至此以从冲刺中删除 show_previous_sprints=显示以前的冲刺 show_future_sprints=显示未来冲刺 -unsorted_projects=Unsorted Projects +unsorted_projects=未分类的项目 +unsorted_items=未分类的项目 ; Administration +release_available=新版本可供升级 +releases=查看发布 version=版本 update_available=有可用更新 update_to_n=更新到 {0} @@ -294,21 +311,23 @@ backup_db=您确定吗? \n在继续之前,建议先备份您的数据库。 ; Admin Overview Tab overview=概况 -database=Database +database=数据库 hostname=主机名 -schema=Schema +mailbox=郵箱 +schema=架构 current_version=当前版本 -cache=Cache -clear_cache=Clear Cache -timeouts=Timeouts -queries=Queries -minify=Minify -attachments=Attachments -smtp_not_enabled=SMTP is not enabled. -imap_not_enabled=IMAP is not enabled. -miscellaneous=Miscellaneous -session_lifetime=Session Lifetime -max_rating=Max Rating +cache=缓存 +clear_cache=清除缓存 +timeouts=逾時 +queries=查询 +minify=最小化 +attachments=附件 +smtp_not_enabled=未启用 SMTP。 +imap_not_enabled=未启用 IMAP。 +miscellaneous=其他 +session_lifetime=連線效期 +max_rating=最大额定值 +mailbox_help=不确定使用什么?请参阅文档。 ; Admin Configuration Tab ; Site Basics Section @@ -319,21 +338,21 @@ timezone=时区 default_theme=默认主题 logo=标志 ; Issue Types and Statuses Section -issue_types_and_statuses=Issue Types and Statuses -issue_types=Issue Types -issue_statuses=Issue Statuses -yes=Yes -no=No -taskboard_columns=Taskboard Columns -taskboard_sort=Taskboard Sort +issue_types_and_statuses=问题的类型和状态 +issue_types=问题类型 +issue_statuses=问题状态 +yes=是 +no=否 +taskboard_columns=任務版欄位 +taskboard_sort=任务板排序 ; Text Parsing Section text_parsing=文本解析 parser_syntax=解析器语法 advanced_options=高级选项 (默认全部启用) convert_ids=转换 ID 为链接 -convert_hashtags=转换#标签#为链接 +convert_hashtags="转换#标签#为链接" convert_urls=转换 URL 为链接 -convert_emoticons=转换表情符号为图片 +convert_emoticons=将文字表情转换为Emoji ; Email Section email_smtp_imap=电子邮件 (SMTP/IMAP) config_note=备注 @@ -346,13 +365,13 @@ imap_truncate_lines=IMAP 邮件截断行 (mail.truncate_lines) imap_settings_note=这里的 IMAP 设置只有在计划任务 {0} 运行时才起作用。 ; Advanced Section advanced=高级选项 -security=Security -min_pass_len=Minimum Password Length +security=安全 +min_pass_len=最小密码长度 censor_credit_card_numbers=审查信用卡号码(security.block_ccs) cookie_expiration=Cookie 有效期 (JAR.expire) max_upload_size=最大上传文件大小 (files.maxsize) allow_public_registration=允许公开注册 -core=Core +core=核心 debug_level=调试级别 (DEBUG) cache_mode=缓存模式 (CACHE) demo_user=演示用户 (site.demo) @@ -392,7 +411,7 @@ group_name_saved=群组名称已保存 manager=经理 set_as_manager=设为经理 add_to_group=添加到组 -api_visible=Visible to API +api_visible=顯示於 API介面 ; Admin Sprints Tab ; Sprint Modal diff --git a/app/helper/cli.php b/app/helper/cli.php new file mode 100644 index 00000000..8d7ff93a --- /dev/null +++ b/app/helper/cli.php @@ -0,0 +1,94 @@ + default values + * @param array $argv + * @return array|null + */ + public function parseOptions(array $options, ?array $argv = null): ?array + { + $keys = array_keys($options); + if ($argv === null) { + $argv = $_SERVER['argv']; + } + + // Show argument help + if (getopt('h', ['help']) || (is_countable($argv) ? count($argv) : 0) == 1) { + $this->showHelp($keys, $options); + return null; + } + + // Parse options + $data = getopt('', $keys); + + // Check that required options are set + foreach ($keys as $key) { + if (substr_count($key, ':') != 1) { + continue; + } + $o = rtrim($key, ':'); + if (!array_key_exists($o, $data)) { + echo "Required argument --$o not specified.", PHP_EOL; + exit(1); + } + } + + // Fill result with defaults + $result = []; + foreach ($keys as $o) { + $key = rtrim($o, ':'); + $result[$key] = $data[$key] ?? $options[$o]; + } + return $result; + } + + /** + * Output help message showing parseOptions() options and defaults + */ + protected function showHelp(array $options, array $defaultMap): void + { + $required = []; + $optional = []; + $flags = []; + foreach ($options as $o) { + $colons = substr_count($o, ':'); + if ($colons == 2) { + $optional[] = $o; + } elseif ($colons == 1) { + $required[] = $o; + } else { + $flags[] = $o; + } + } + + echo 'Required values:', PHP_EOL; + foreach ($required as $key) { + $o = rtrim($key, ':'); + echo "--$o={$defaultMap[$key]}", PHP_EOL; + } + echo PHP_EOL; + + echo 'Optional values:', PHP_EOL; + foreach ($optional as $key) { + $o = rtrim($key, ':'); + echo "--$o={$defaultMap[$key]}", PHP_EOL; + } + echo PHP_EOL; + + echo 'Optional flags:', PHP_EOL; + foreach ($flags as $key) { + $o = rtrim($key, ':'); + echo "--$o", PHP_EOL; + } + echo PHP_EOL; + } +} diff --git a/app/helper/dashboard.php b/app/helper/dashboard.php index 29b0d956..3a25685e 100644 --- a/app/helper/dashboard.php +++ b/app/helper/dashboard.php @@ -2,239 +2,313 @@ namespace Helper; -class Dashboard extends \Prefab { - - protected - $_issue, - $_ownerIds, - $_projects, - $_order = "priority DESC, has_due_date ASC, due_date ASC"; - - public function getIssue() { - return $this->_issue === null ? $this->_issue = new \Model\Issue\Detail : $this->_issue; - } - - public function getOwnerIds() { - if($this->_ownerIds) { - return $this->_ownerIds; - } - $f3 = \Base::instance(); - $this->_ownerIds = array($f3->get("user.id")); - $groups = new \Model\User\Group(); - foreach($groups->find(array("user_id = ?", $f3->get("user.id"))) as $r) { - $this->_ownerIds[] = $r->group_id; - } - return $this->_ownerIds; - } - - public function projects() { - $f3 = \Base::instance(); - $ownerString = implode(",", $this->getOwnerIds()); - $this->_projects = $this->getIssue()->find( - array( - "owner_id IN ($ownerString) AND type_id=:type AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0", - ":type" => $f3->get("issue_type.project"), - ), - array("order" => $this->_order) - ); - return $this->_projects; - } - - public function subprojects() { - if($this->_projects === null) { - $this->projects(); - } - - $projects = $this->_projects; - $subprojects = array(); - foreach($projects as $i=>$project) { - if($project->parent_id) { - $subprojects[] = $project; - unset($projects[$i]); - } - } - - return $subprojects; - } - - public function bugs() { - $f3 = \Base::instance(); - $ownerString = implode(",", $this->getOwnerIds()); - return $this->getIssue()->find( - array( - "owner_id IN ($ownerString) AND type_id=:type AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0", - ":type" => $f3->get("issue_type.bug"), - ), - array("order" => $this->_order) - ); - } - - public function repeat_work() { - $ownerString = implode(",", $this->getOwnerIds()); - return $this->getIssue()->find( - "owner_id IN ($ownerString) AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0 AND repeat_cycle IS NOT NULL", - array("order" => $this->_order) - ); - } - - public function watchlist() { - $f3 = \Base::instance(); - $watchlist = new \Model\Issue\Watcher(); - return $watchlist->findby_watcher($f3->get("user.id"), $this->_order); - } - - public function tasks() { - $f3 = \Base::instance(); - $ownerString = implode(",", $this->getOwnerIds()); - return $this->getIssue()->find( - array( - "owner_id IN ($ownerString) AND type_id=:type AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0", - ":type" => $f3->get("issue_type.task"), - ), - array("order" => $this->_order) - ); - } - - public function my_comments() { - $f3 = \Base::instance(); - $comment = new \Model\Issue\Comment\Detail; - return $comment->find(array("user_id = ?", $f3->get("user.id")), array("order" => "created_date DESC", "limit" => 10)); - } - - public function recent_comments() { - $f3 = \Base::instance(); - - $issue = new \Model\Issue; - $ownerString = implode(",", $this->getOwnerIds()); - $issues = $issue->find(array("owner_id IN ($ownerString) OR author_id = ? AND deleted_date IS NULL", $f3->get("user.id"))); - if(!$issues) { - return array(); - } - - $ids = array(); - foreach($issues as $item) { - $ids[] = $item->id; - } - - $comment = new \Model\Issue\Comment\Detail; - $issueIds = implode(",", $ids); - return $comment->find(array("issue_id IN ($issueIds) AND user_id != ?", $f3->get("user.id")), array("order" => "created_date DESC", "limit" => 15)); - } - - public function open_comments() { - $f3 = \Base::instance(); - - $issue = new \Model\Issue; - $ownerString = implode(",", $this->getOwnerIds()); - $issues = $issue->find(array("(owner_id IN ($ownerString) OR author_id = ?) AND closed_date IS NULL AND deleted_date IS NULL", $f3->get("user.id"))); - if(!$issues) { - return array(); - } - - $ids = array(); - foreach($issues as $item) { - $ids[] = $item->id; - } - - $comment = new \Model\Issue\Comment\Detail; - $issueIds = implode(",", $ids); - return $comment->find(array("issue_id IN ($issueIds) AND user_id != ?", $f3->get("user.id")), array("order" => "created_date DESC", "limit" => 15)); - } - - /** - * Get data for Issue Tree widget - * @return array - */ - public function issue_tree() { - $f3 = \Base::instance(); - - // Load assigned issues - $issue = new \Model\Issue\Detail; - $assigned = $issue->find(array("closed_date IS NULL AND deleted_date IS NULL AND owner_id = ?", $f3->get("user.id"))); - - // Build issue list - $issues = array(); - $assigned_ids = array(); - $missing_ids = array(); - foreach($assigned as $iss) { - $issues[] = $iss->cast(); - $assigned_ids[] = $iss->id; - } - foreach($issues as $iss) { - if($iss["parent_id"] && !in_array($iss["parent_id"], $assigned_ids)) { - $missing_ids[] = $iss["parent_id"]; - } - } - while(!empty($missing_ids)) { - $parents = $issue->find("id IN (" . implode(",", $missing_ids) . ")"); - foreach($parents as $iss) { - if (($key = array_search($iss->id, $missing_ids)) !== false) { - unset($missing_ids[$key]); - } - $issues[] = $iss->cast(); - $assigned_ids[] = $iss->id; - if($iss->parent_id && !in_array($iss->parent_id, $assigned_ids)) { - $missing_ids[] = $iss->parent_id; - } - } - } - - // Convert list to tree - $tree = $this->_buildTree($issues); - - /** - * Helper function for recursive tree rendering - * @param array $issue - * @var callable $renderTree This function, required for recursive calls - */ - $renderTree = function(&$issue, $level = 0) use(&$renderTree) { - if(!empty($issue['id'])) { - $f3 = \Base::instance(); - $hive = array("issue" => $issue, "dict" => $f3->get("dict"), "BASE" => $f3->get("BASE"), "level" => $level, "issue_type" => $f3->get("issue_type")); - echo \Helper\View::instance()->render("issues/project/tree-item.html", "text/html", $hive); - if(!empty($issue['children'])) { - foreach($issue['children'] as $item) { - $renderTree($item, $level + 1); - } - } - } - }; - $f3->set("renderTree", $renderTree); - - return $tree; - } - - /** - * Convert a flat issue array to a tree array. Child issues are added to - * the 'children' key in each issue. - * @param array $array Flat array of issues, including all parents needed - * @return array Tree array where each issue contains its child issues - */ - protected function _buildTree($array) { - $tree = array(); - - // Create an associative array with each key being the ID of the item - foreach($array as $k => &$v) { - $tree[$v['id']] = &$v; - } - - // Loop over the array and add each child to their parent - foreach($tree as $k => &$v) { - if(empty($v['parent_id'])) { - continue; - } - $tree[$v['parent_id']]['children'][] = &$v; - } - - // Loop over the array again and remove any items that don't have a parent of 0; - foreach($tree as $k => &$v) { - if(empty($v['parent_id'])) { - continue; - } - unset($tree[$k]); - } - - return $tree; - } - +class Dashboard extends \Prefab +{ + protected $_issue; + protected $_ownerIds; + protected $_groupIds; + protected $_groupUserIds; + protected $_projects; + protected $_order = "priority DESC, has_due_date ASC, due_date ASC, created_date DESC"; + + public $defaultConfig = [ + "left" => ["projects", "subprojects", "bugs", "repeat_work", "watchlist"], + "right" => ["tasks"] + ]; + + public $allWidgets = ["projects", "subprojects", "tasks", "bugs", "repeat_work", "watchlist", "my_comments", "recent_comments", "open_comments", "issue_tree"]; + + public function getIssue() + { + return $this->_issue ?? ($this->_issue = new \Model\Issue\Detail()); + } + + public function getOwnerIds() + { + if ($this->_ownerIds) { + return $this->_ownerIds; + } + $f3 = \Base::instance(); + $this->_ownerIds = [$f3->get("user.id")]; + $groups = new \Model\User\Group(); + foreach ($groups->find(["user_id = ?", $f3->get("user.id")]) as $r) { + $this->_ownerIds[] = $r->group_id; + } + return $this->_ownerIds; + } + + public function getGroupIds() + { + if ($this->_groupIds !== null) { + return $this->_groupIds; + } + $f3 = \Base::instance(); + $groups = new \Model\User\Group(); + foreach ($groups->find(["user_id = ?", $f3->get("user.id")]) as $r) { + $this->_groupIds[] = $r->group_id; + } + return $this->_groupIds; + } + + public function getGroupUserIds() + { + if ($this->_groupUserIds !== null) { + return $this->_groupUserIds; + } + $groups = new \Model\User\Group(); + $groupString = implode(",", $this->getGroupIds()); + foreach ($groups->find(["group_id IN (" . $groupString . ")"]) as $r) { + $this->_groupUserIds[] = $r->user_id; + } + return $this->_groupUserIds; + } + + public function projects() + { + $f3 = \Base::instance(); + $typeIds = []; + foreach ($f3->get('issue_types') as $t) { + if ($t->role == 'project') { + $typeIds[] = $t->id; + } + } + if (!$typeIds) { + return []; + } + $ownerString = implode(",", $this->getOwnerIds()); + $typeIdStr = implode(",", $typeIds); + $this->_projects = $this->getIssue()->find( + ["owner_id IN ($ownerString) AND type_id IN ($typeIdStr) AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0"], + ["order" => $this->_order] + ); + return $this->_projects; + } + + public function subprojects() + { + if ($this->_projects === null) { + $this->projects(); + } + + $projects = $this->_projects; + $subprojects = []; + foreach ($projects as $i => $project) { + if ($project->parent_id) { + $subprojects[] = $project; + unset($projects[$i]); + } + } + + return $subprojects; + } + + public function bugs() + { + $f3 = \Base::instance(); + $typeIds = []; + foreach ($f3->get('issue_types') as $t) { + if ($t->role == 'bug') { + $typeIds[] = $t->id; + } + } + if (!$typeIds) { + return []; + } + $ownerString = implode(",", $this->getOwnerIds()); + $typeIdStr = implode(",", $typeIds); + return $this->getIssue()->find( + ["owner_id IN ($ownerString) AND type_id IN ($typeIdStr) AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0"], + ["order" => $this->_order] + ); + } + + public function repeat_work() + { + $ownerString = implode(",", $this->getOwnerIds()); + return $this->getIssue()->find( + "owner_id IN ($ownerString) AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0 AND repeat_cycle IS NOT NULL", + ["order" => $this->_order] + ); + } + + public function watchlist() + { + $f3 = \Base::instance(); + $watchlist = new \Model\Issue\Watcher(); + return $watchlist->findby_watcher($f3->get("user.id"), $this->_order); + } + + public function tasks() + { + $f3 = \Base::instance(); + $typeIds = []; + foreach ($f3->get('issue_types') as $t) { + if ($t->role == 'task') { + $typeIds[] = $t->id; + } + } + if (!$typeIds) { + return []; + } + $ownerString = implode(",", $this->getOwnerIds()); + $typeIdStr = implode(",", $typeIds); + return $this->getIssue()->find( + ["owner_id IN ($ownerString) AND type_id IN ($typeIdStr) AND deleted_date IS NULL AND closed_date IS NULL AND status_closed = 0"], + ["order" => $this->_order] + ); + } + + public function my_comments() + { + $f3 = \Base::instance(); + $comment = new \Model\Issue\Comment\Detail(); + return $comment->find(["user_id = ? AND issue_deleted_date IS NULL", $f3->get("user.id")], ["order" => "created_date DESC", "limit" => 10]); + } + + public function recent_comments() + { + $f3 = \Base::instance(); + + $issue = new \Model\Issue(); + $ownerString = implode(",", $this->getOwnerIds()); + $issues = $issue->find(["owner_id IN ($ownerString) OR author_id = ? AND deleted_date IS NULL", $f3->get("user.id")]); + if (!$issues) { + return []; + } + + $ids = []; + foreach ($issues as $item) { + $ids[] = $item->id; + } + + if (!$ids) { + return []; + } + $issueIds = implode(",", $ids); + $comment = new \Model\Issue\Comment\Detail(); + return $comment->find(["issue_id IN ($issueIds) AND user_id != ?", $f3->get("user.id")], ["order" => "created_date DESC", "limit" => 15]); + } + + public function open_comments() + { + $f3 = \Base::instance(); + + $issue = new \Model\Issue(); + $ownerString = implode(",", $this->getOwnerIds()); + $issues = $issue->find(["(owner_id IN ($ownerString) OR author_id = ?) AND closed_date IS NULL AND deleted_date IS NULL", $f3->get("user.id")]); + if (!$issues) { + return []; + } + + $ids = []; + foreach ($issues as $item) { + $ids[] = $item->id; + } + + if (!$ids) { + return []; + } + $issueIds = implode(",", $ids); + $comment = new \Model\Issue\Comment\Detail(); + return $comment->find(["issue_id IN ($issueIds) AND user_id != ?", $f3->get("user.id")], ["order" => "created_date DESC", "limit" => 15]); + } + + /** + * Get data for Issue Tree widget + * @return array + */ + public function issue_tree() + { + $f3 = \Base::instance(); + $userId = $f3->get("this_user") ? $f3->get("this_user")->id : $f3->get("user.id"); + + // Load assigned issues + $issue = new \Model\Issue\Detail(); + $assigned = $issue->find(["closed_date IS NULL AND deleted_date IS NULL AND owner_id = ?", $userId]); + + // Build issue list + $issues = []; + $assigned_ids = []; + $missing_ids = []; + foreach ($assigned as $iss) { + $issues[] = $iss->cast(); + $assigned_ids[] = $iss->id; + } + foreach ($issues as $iss) { + if ($iss["parent_id"] && !in_array($iss["parent_id"], $assigned_ids)) { + $missing_ids[] = $iss["parent_id"]; + } + } + while (!empty($missing_ids)) { + $parents = $issue->find("id IN (" . implode(",", $missing_ids) . ")"); + foreach ($parents as $iss) { + if (($key = array_search($iss->id, $missing_ids)) !== false) { + unset($missing_ids[$key]); + } + $issues[] = $iss->cast(); + $assigned_ids[] = $iss->id; + if ($iss->parent_id && !in_array($iss->parent_id, $assigned_ids)) { + $missing_ids[] = $iss->parent_id; + } + } + } + + // Convert list to tree + $tree = $this->_buildTree($issues); + + /** + * Helper function for recursive tree rendering + * @param array $issue + * @var callable $renderTree This function, required for recursive calls + */ + $renderTree = function (&$issue, $level = 0) use (&$renderTree) { + if (!empty($issue['id'])) { + $f3 = \Base::instance(); + $hive = ["issue" => $issue, "dict" => $f3->get("dict"), "BASE" => $f3->get("BASE"), "level" => $level, "issue_type" => $f3->get("issue_type")]; + echo \Helper\View::instance()->render("issues/project/tree-item.html", "text/html", $hive); + if (!empty($issue['children'])) { + foreach ($issue['children'] as $item) { + $renderTree($item, $level + 1); + } + } + } + }; + $f3->set("renderTree", $renderTree); + + return $tree; + } + + /** + * Convert a flat issue array to a tree array. Child issues are added to + * the 'children' key in each issue. + * @param array $array Flat array of issues, including all parents needed + * @return array Tree array where each issue contains its child issues + */ + protected function _buildTree($array) + { + $tree = []; + + // Create an associative array with each key being the ID of the item + foreach ($array as $k => &$v) { + $tree[$v['id']] = &$v; + } + + // Loop over the array and add each child to their parent + foreach ($tree as $k => &$v) { + if (empty($v['parent_id'])) { + continue; + } + $tree[$v['parent_id']]['children'][] = &$v; + } + + // Loop over the array again and remove any items that don't have a parent of 0; + foreach ($tree as $k => &$v) { + if (empty($v['parent_id'])) { + continue; + } + unset($tree[$k]); + } + + return $tree; + } } diff --git a/app/helper/file.php b/app/helper/file.php new file mode 100644 index 00000000..e77bd98e --- /dev/null +++ b/app/helper/file.php @@ -0,0 +1,55 @@ + [ + "image/jpeg", + "image/png", + "image/gif", + "image/bmp", + ], + "icon" => [ + "audio/.+" => "_audio", + "application/.*zip" => "_archive", + "application/x-php" => "_code", + "(application|text)/xml" => "_code", + "text/html" => "_code", + "image/.+" => "_image", + "application/x-photoshop" => "_image", + "video/.+" => "_video", + "application/.*pdf" => "pdf", + "text/[ct]sv" => "csv", + "text/.+-separated-values" => "csv", + "text/.+" => "txt", + "application/sql" => "txt", + "application/vnd\.oasis\.opendocument\.graphics" => "odg", + "application/vnd\.oasis\.opendocument\.spreadsheet" => "ods", + "application/vnd\.oasis\.opendocument\.presentation" => "odp", + "application/vnd\.oasis\.opendocument\.text" => "odt", + "application/(msword|vnd\.(ms-word|openxmlformats-officedocument\.wordprocessingml.+))" => "doc", + "application/(msexcel|vnd\.(ms-excel|openxmlformats-officedocument\.spreadsheetml.+))" => "xls", + "application/(mspowerpoint|vnd\.(ms-powerpoint|openxmlformats-officedocument\.presentationml.+))" => "ppt", + ], + ]; + + /** + * Get an icon name by MIME type + * + * Returns "_blank" when no icon matches + * + * @param string $contentType + * @return string + */ + public static function mimeIcon($contentType) + { + foreach (self::$mimeMap["icon"] as $regex => $name) { + if (preg_match("@^" . $regex . "$@i", $contentType)) { + return $name; + } + } + return "_blank"; + } +} diff --git a/app/helper/image.php b/app/helper/image.php deleted file mode 100644 index a1c2232d..00000000 --- a/app/helper/image.php +++ /dev/null @@ -1,89 +0,0 @@ -last_data; - } - - /** - * Create a new blank canvase - * @param int $width - * @param int $height - * @return Image - */ - function create($width, $height) { - $this->data = imagecreatetruecolor($width, $height); - imagesavealpha($this->data, true); - } - - /** - * Render a line of text - * @param string $text - * @param float $size - * @param integer $angle - * @param integer $x - * @param integer $y - * @param hex $color - * @param string $font - * @param hex $overlay_color - * @param float $overlay_transparency - * @param integer $overlay_padding - * @return Image - */ - function text($text, $size = 9.0, $angle = 0, $x = 0, $y = 0, $color = 0x000000, $font = "opensans-regular.ttf", - $overlay_color = null, $overlay_transparency = 0.5, $overlay_padding = 2 - ) { - $f3 = \Base::instance(); - - $font = $f3->get("ROOT") . "/fonts/" . $font; - if(!is_file($font)) { - $f3->error(500, "Font file not found"); - return false; - } - - $color = $this->rgb($color); - $color_id = imagecolorallocate($this->data, $color[0], $color[1], $color[2]); - - $bbox = imagettfbbox($size, $angle, $font, "M"); - $y += $bbox[3] - $bbox[5]; - - if(!is_null($overlay_color)) { - $overlay_bbox = imagettfbbox($size, $angle, $font, $text); - $overlay_color = $this->rgb($overlay_color); - $overlay_color_id = imagecolorallocatealpha($this->data, $overlay_color[0], $overlay_color[1], $overlay_color[2], $overlay_transparency * 127); - imagefilledrectangle( - $this->data, - $x - $overlay_padding, - $y - $overlay_padding, - $x + $overlay_bbox[2] - $overlay_bbox[0] + $overlay_padding, - $y + $overlay_bbox[3] - $overlay_bbox[5] + $overlay_padding, - $overlay_color_id - ); - } - - $this->last_data = imagettftext($this->data, $size, $angle, $x, $y, $color_id, $font, $text); - return $this->save(); - } - - /** - * Fill image with a solid color - * @param hex $color - * @return Image - */ - function fill($color = 0x000000) { - $color = $this->rgb($color); - $color_id = imagecolorallocate($this->data, $color[0], $color[1], $color[2]); - imagefill($this->data, 0, 0, $color_id); - return $this->save(); - } - -} diff --git a/app/helper/matrix.php b/app/helper/matrix.php new file mode 100644 index 00000000..b10e74a2 --- /dev/null +++ b/app/helper/matrix.php @@ -0,0 +1,41 @@ + $v) { + $lengths[$k] = is_countable($v) ? count($v) : 0; + } + $max = max($lengths); + $result = []; + for ($i = 0; $i < $max; $i++) { + foreach ($lengths as $k => $l) { + if ($l > $i) { + $result[] = $arrays[$k][$i]; + } + } + } + return $result; + } + + /** + * Run array_merge on an array of arrays + * + * @param array $arrays + * @return array + */ + public function merge(array $arrays) + { + return call_user_func_array("array_merge", $arrays); + } +} diff --git a/app/helper/notification.php b/app/helper/notification.php index 87bc3039..17e403e5 100644 --- a/app/helper/notification.php +++ b/app/helper/notification.php @@ -2,347 +2,428 @@ namespace Helper; -class Notification extends \Prefab { - - /** - * Send an email with the UTF-8 character set - * @param string $to - * @param string $subject - * @param string $body The HTML body part - * @param string $text The plaintext body part (optional) - * @return bool - */ - public function utf8mail($to, $subject, $body, $text = null) { - $f3 = \Base::instance(); - - // Add basic headers - $headers = 'MIME-Version: 1.0' . "\r\n"; - $headers .= 'To: '. $to . "\r\n"; - $headers .= 'From: '. $f3->get("mail.from") . "\r\n"; - - // Build multipart message if necessary - if($text) { - // Generate message breaking hash - $hash = md5(date("r")); - $headers .= "Content-Type: multipart/alternative; boundary=\"$hash\"\r\n"; - - // Normalize line endings - $body = str_replace("\r\n", "\n", $body); - $body = str_replace("\n", "\r\n", $body); - $text = str_replace("\r\n", "\n", $text); - $text = str_replace("\n", "\r\n", $text); - - // escape first char dots per line - $body = preg_replace('/^\.(.+)/m','..$1', - quoted_printable_encode($body)); - $text = preg_replace('/^\.(.+)/m','..$1', - quoted_printable_encode($text)); - - // Build final message - $msg = "--$hash\r\n"; - $msg .= "Content-Type: text/plain; charset=utf-8\r\n"; - $msg .= "Content-Transfer-Encoding: quoted-printable\r\n"; - $msg .= "\r\n" . $text . "\r\n"; - $msg .= "--$hash\r\n"; - $msg .= "Content-Type: text/html; charset=utf-8\r\n"; - $msg .= "Content-Transfer-Encoding: quoted-printable\r\n"; - $msg .= "\r\n" . $body . "\r\n"; - $msg .= "--$hash--\r\n"; - - $body = $msg; - } else { - $headers .= "Content-Type: text/html; charset=utf-8\r\n"; - } - - return mail($to, $subject, $body, $headers); - } - - /** - * Send an email to watchers with the comment body - * @param int $issue_id - * @param int $comment_id - */ - public function issue_comment($issue_id, $comment_id) { - $f3 = \Base::instance(); - if($f3->get("mail.from")) { - $log = new \Log("mail.log"); - - // Get issue and comment data - $issue = new \Model\Issue; - $issue->load($issue_id); - $comment = new \Model\Issue\Comment\Detail; - $comment->load($comment_id); - - // Get issue parent if set - if($issue->parent_id) { - $parent = new \Model\Issue; - $parent->load($issue->parent_id); - $f3->set("parent", $parent); - } - - // Get recipient list and remove current user - $recipients = $this->_issue_watchers($issue_id); - $recipients = array_diff($recipients, array($comment->user_email)); - - // Render message body - $f3->set("issue", $issue); - $f3->set("comment", $comment); - $text = $this->_render("notification/comment.txt"); - $body = $this->_render("notification/comment.html"); - - $subject = "[#{$issue->id}] - New comment on {$issue->name}"; - - // Send to recipients - foreach($recipients as $recipient) { - $this->utf8mail($recipient, $subject, $body, $text); - $log->write("Sent comment notification to: " . $recipient); - } - } - } - - /** - * Send an email to watchers detailing the updated fields - * @param int $issue_id - * @param int $update_id - */ - public function issue_update($issue_id, $update_id) { - $f3 = \Base::instance(); - if($f3->get("mail.from")) { - $log = new \Log("mail.log"); - - // Get issue and update data - $issue = new \Model\Issue(); - $issue->load($issue_id); - $f3->set("issue", $issue); - $update = new \Model\Custom("issue_update_detail"); - $update->load($update_id); - - // Get issue parent if set - if($issue->parent_id) { - $parent = new \Model\Issue; - $parent->load($issue->parent_id); - $f3->set("parent", $parent); - } - - // Avoid errors from bad calls - if(!$issue->id || !$update->id) { - return false; - } - - $changes = new \Model\Issue\Update\Field(); - $f3->set("changes", $changes->find(array("issue_update_id = ?", $update->id))); - - // Get recipient list and remove update user - $recipients = $this->_issue_watchers($issue_id); - $recipients = array_diff($recipients, array($update->user_email)); - - // Render message body - $f3->set("issue", $issue); - $f3->set("update", $update); - $text = $this->_render("notification/update.txt"); - $body = $this->_render("notification/update.html"); - - $changes->load(array("issue_update_id = ? AND `field` = 'closed_date' AND old_value = '' and new_value != ''", $update->id)); - if($changes && $changes->id) { - $subject = "[#{$issue->id}] - {$issue->name} closed"; - } else { - $subject = "[#{$issue->id}] - {$issue->name} updated"; - } - - - - // Send to recipients - foreach($recipients as $recipient) { - $this->utf8mail($recipient, $subject, $body, $text); - $log->write("Sent update notification to: " . $recipient); - } - } - } - - /** - * Send an email to watchers detailing the updated fields - * @param int $issue_id - */ - public function issue_create($issue_id) { - $f3 = \Base::instance(); - $log = new \Log("mail.log"); - if($f3->get("mail.from")) { - $log = new \Log("mail.log"); - - // Get issue and update data - $issue = new \Model\Issue\Detail(); - $issue->load($issue_id); - $f3->set("issue", $issue); - - // Get issue parent if set - if($issue->parent_id) { - $parent = new \Model\Issue; - $parent->load($issue->parent_id); - $f3->set("parent", $parent); - } - - // Get recipient list, keeping current user - $recipients = $this->_issue_watchers($issue_id); - - // Render message body - $f3->set("issue", $issue); - - $text = $this->_render("notification/new.txt"); - $body = $this->_render("notification/new.html"); - - $subject = "[#{$issue->id}] - {$issue->name} created by {$issue->author_name}"; - - // Send to recipients - foreach($recipients as $recipient) { - $this->utf8mail($recipient, $subject, $body, $text); - $log->write("Sent create notification to: " . $recipient); - } - } - } - - /** - * Send an email to watchers with the file info - * @param int $issue_id - * @param int $file_id - */ - public function issue_file($issue_id, $file_id) { - $f3 = \Base::instance(); - if($f3->get("mail.from")) { - $log = new \Log("mail.log"); - - // Get issue and comment data - $issue = new \Model\Issue; - $issue->load($issue_id); - $file = new \Model\Issue\File\Detail; - $file->load($file_id); - - // This should catch a bug I can't currently find the source of. --Alan - if($file->issue_id != $issue->id) { - return; - } - - // Get issue parent if set - if($issue->parent_id) { - $parent = new \Model\Issue; - $parent->load($issue->parent_id); - $f3->set("parent", $parent); - } - - // Get recipient list and remove current user - $recipients = $this->_issue_watchers($issue_id); - $recipients = array_diff($recipients, array($file->user_email)); - - // Render message body - $f3->set("issue", $issue); - $f3->set("file", $file); - $text = $this->_render("notification/file.txt"); - $body = $this->_render("notification/file.html"); - - $subject = "[#{$issue->id}] - {$file->user_name} attached a file to {$issue->name}"; - - // Send to recipients - foreach($recipients as $recipient) { - $this->utf8mail($recipient, $subject, $body, $text); - $log->write("Sent file notification to: " . $recipient); - } - } - } - - /** - * Send a user a password reset email - * @param int $user_id - */ - public function user_reset($user_id) { - $f3 = \Base::instance(); - if($f3->get("mail.from")) { - $user = new \Model\User; - $user->load($user_id); - - if(!$user->id) { - throw new Exception("User does not exist."); - } - - // Render message body - $f3->set("user", $user); - $text = $this->_render("notification/user_reset.txt"); - $body = $this->_render("notification/user_reset.html"); - - // Send email to user - $subject = "Reset your password - " . $f3->get("site.name"); - $this->utf8mail($user->email, $subject, $body, $text); - } - } - - /** - * Send a user an email listing the issues due today - * @param ModelUser $user - * @param array $issues - * @return bool - */ - public function user_due_issues(\Model\User $user, array $issues) { - $f3 = \Base::instance(); - if($f3->get("mail.from")) { - $f3->set("issues", $issues); - $subject = "Due Today - " . $f3->get("site.name"); - $text = $this->_render("notification/user_due_issues.txt"); - $body = $this->_render("notification/user_due_issues.html"); - return $this->utf8mail($user->email, $subject, $body, $text); - } - return false; - } - - /** - * Get array of email addresses of all watchers on an issue - * @param int $issue_id - * @return array - */ - protected function _issue_watchers($issue_id) { - $db = \Base::instance()->get("db.instance"); - $recipients = array(); - - // Add issue author and owner - $result = $db->exec("SELECT u.email FROM issue i INNER JOIN `user` u on i.author_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id); - if(!empty($result[0]["email"])) { - $recipients[] = $result[0]["email"]; - } - - - $result = $db->exec("SELECT u.email FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id); - if(!empty($result[0]["email"])) { - $recipients[] = $result[0]["email"]; - } - - // Add whole group - $result = $db->exec("SELECT u.role, u.id FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id); - if($result && $result[0]["role"] == 'group') { - $group_users = $db->exec("SELECT g.user_email FROM user_group_user g WHERE g.group_id = ?", $result[0]["id"]); - foreach($group_users as $group_user) { - if(!empty($group_user["user_email"])) { - $recipients[] = $group_user["user_email"]; - } - } - } - - // Add watchers - $watchers = $db->exec("SELECT u.email FROM issue_watcher w INNER JOIN `user` u ON w.user_id = u.id WHERE u.deleted_date IS NULL AND issue_id = ?", $issue_id); - foreach($watchers as $watcher) { - $recipients[] = $watcher["email"]; - } - - // Remove duplicate users - return array_unique($recipients); - } - - /** - * Render a view and return the result - * @param string $file - * @param string $mime - * @param array $hive - * @param integer $ttl - * @return string - */ - protected function _render($file, $mime = "text/html", array $hive = null, $ttl = 0) { - return \Helper\View::instance()->render($file, $mime, $hive, $ttl); - } - +class Notification extends \Prefab +{ + public const QPRINT_MAXL = 75; + + /** + * Convert a 8 bit string to a quoted-printable string + * + * Modified to add =2E instead of the leading double dot, see GH #238 + * + * @link http://php.net/manual/en/function.quoted-printable-encode.php#115840 + * + * @param string $str + * @return string + */ + public function quotePrintEncode($str) + { + $lp = 0; + $ret = ''; + $hex = "0123456789ABCDEF"; + $length = strlen($str); + $str_index = 0; + + while ($length--) { + if ((($c = $str[$str_index++]) == "\015") && ($str[$str_index] == "\012") && $length > 0) { + $ret .= "\015"; + $ret .= $str[$str_index++]; + $length--; + $lp = 0; + } else { + if ( + ctype_cntrl($c) + || (ord($c) == 0x7f) + || (ord($c) & 0x80) + || ($c == '=') + || (($c == ' ') && ($str[$str_index] == "\015")) + ) { + if (($lp += 3) > self::QPRINT_MAXL) { + $ret .= '='; + $ret .= "\015"; + $ret .= "\012"; + $lp = 3; + } + $ret .= '='; + $ret .= $hex[ord($c) >> 4]; + $ret .= $hex[ord($c) & 0xf]; + } else { + if ((++$lp) > self::QPRINT_MAXL) { + $ret .= '='; + $ret .= "\015"; + $ret .= "\012"; + $lp = 1; + } + $ret .= $c; + if ($lp == 1 && $c == '.') { + $ret = substr($ret, 0, strlen($ret) - 1); + $ret .= '=2E'; + $lp++; + } + } + } + } + + return $ret; + } + + /** + * Send an email with the UTF-8 character set + * @param string $to + * @param string $subject + * @param string $body The HTML body part + * @param string $text The plaintext body part (optional) + * @return bool + */ + public function utf8mail($to, $subject, $body, $text = null) + { + $f3 = \Base::instance(); + + // Add basic headers + $headers = 'MIME-Version: 1.0' . "\r\n"; + $headers .= 'From: ' . $f3->get("mail.from") . "\r\n"; + + // Build multipart message if necessary + if ($text) { + // Generate message breaking hash + $hash = md5(date("r")); + $headers .= "Content-Type: multipart/alternative; boundary=\"$hash\"\r\n"; + + // Normalize line endings + $body = str_replace("\r\n", "\n", $body); + $body = str_replace("\n", "\r\n", $body); + $text = str_replace("\r\n", "\n", $text); + $text = str_replace("\n", "\r\n", $text); + + // Encode content + $body = $this->quotePrintEncode($body); + $text = $this->quotePrintEncode($text); + + // Build final message + $msg = "--$hash\r\n"; + $msg .= "Content-Type: text/plain; charset=utf-8\r\n"; + $msg .= "Content-Transfer-Encoding: quoted-printable\r\n"; + $msg .= "\r\n" . $text . "\r\n"; + $msg .= "--$hash\r\n"; + $msg .= "Content-Type: text/html; charset=utf-8\r\n"; + $msg .= "Content-Transfer-Encoding: quoted-printable\r\n"; + $msg .= "\r\n" . $body . "\r\n"; + $msg .= "--$hash--\r\n"; + + $body = $msg; + } else { + $headers .= "Content-Type: text/html; charset=utf-8\r\n"; + } + + return mail($to, $subject, $body, $headers); + } + + /** + * Send an email to watchers with the comment body + * @param int $issue_id + * @param int $comment_id + */ + public function issue_comment($issue_id, $comment_id) + { + $f3 = \Base::instance(); + if ($f3->get("mail.from")) { + $log = new \Log("mail.log"); + + // Get issue and comment data + $issue = new \Model\Issue(); + $issue->load($issue_id); + $comment = new \Model\Issue\Comment\Detail(); + $comment->load($comment_id); + + // Get issue parent if set + if ($issue->parent_id) { + $parent = new \Model\Issue(); + $parent->load($issue->parent_id); + $f3->set("parent", $parent); + } + + // Get recipient list and remove current user + $recipients = $this->_issue_watchers($issue_id); + $recipients = array_diff($recipients, [$comment->user_email]); + + // Render message body + $f3->set("issue", $issue); + $f3->set("comment", $comment); + $f3->set("previewText", $comment->text); + $text = $this->_render("notification/comment.txt"); + $body = $this->_render("notification/comment.html"); + + $subject = "[#{$issue->id}] - New comment on {$issue->name}"; + + // Send to recipients + foreach ($recipients as $recipient) { + $this->utf8mail($recipient, $subject, $body, $text); + $log->write("Sent comment notification to: " . $recipient); + } + } + } + + /** + * Send an email to watchers detailing the updated fields + * @param int $issue_id + * @param int $update_id + */ + public function issue_update($issue_id, $update_id) + { + $f3 = \Base::instance(); + if ($f3->get("mail.from")) { + $log = new \Log("mail.log"); + + // Get issue and update data + $issue = new \Model\Issue(); + $issue->load($issue_id); + $f3->set("issue", $issue); + $update = new \Model\Custom("issue_update_detail"); + $update->load($update_id); + + // Get issue parent if set + if ($issue->parent_id) { + $parent = new \Model\Issue(); + $parent->load($issue->parent_id); + $f3->set("parent", $parent); + } + + // Avoid errors from bad calls + if (!$issue->id || !$update->id) { + return false; + } + + $changes = new \Model\Issue\Update\Field(); + $f3->set("changes", $changes->find(["issue_update_id = ?", $update->id])); + + // Get recipient list and remove update user + $recipients = $this->_issue_watchers($issue_id); + $recipients = array_diff($recipients, [$update->user_email]); + + // Render message body + $f3->set("issue", $issue); + $f3->set("update", $update); + $text = $this->_render("notification/update.txt"); + $body = $this->_render("notification/update.html"); + + $changes->load(["issue_update_id = ? AND `field` = 'closed_date' AND old_value = '' and new_value != ''", $update->id]); + if ($changes && $changes->id) { + $subject = "[#{$issue->id}] - {$issue->name} closed"; + } else { + $subject = "[#{$issue->id}] - {$issue->name} updated"; + } + + // Send to recipients + foreach ($recipients as $recipient) { + $this->utf8mail($recipient, $subject, $body, $text); + $log->write("Sent update notification to: " . $recipient); + } + } + } + + /** + * Send an email to watchers detailing the updated fields + * @param int $issue_id + */ + public function issue_create($issue_id) + { + $f3 = \Base::instance(); + $log = new \Log("mail.log"); + if ($f3->get("mail.from")) { + $log = new \Log("mail.log"); + + // Get issue and update data + $issue = new \Model\Issue\Detail(); + $issue->load($issue_id); + $f3->set("issue", $issue); + + // Get issue parent if set + if ($issue->parent_id) { + $parent = new \Model\Issue(); + $parent->load($issue->parent_id); + $f3->set("parent", $parent); + } + + // Get recipient list, conditionally removing the author + $recipients = $this->_issue_watchers($issue_id); + $user = new \Model\User(); + $user->load($issue->author_id); + if ($user->option('disable_self_notifications')) { + $recipients = array_diff($recipients, [$user->email]); + } + + // Render message body + $f3->set("issue", $issue); + + $text = $this->_render("notification/new.txt"); + $body = $this->_render("notification/new.html"); + + $subject = "[#{$issue->id}] - {$issue->name} created by {$issue->author_name}"; + + // Send to recipients + foreach ($recipients as $recipient) { + $this->utf8mail($recipient, $subject, $body, $text); + $log->write("Sent create notification to: " . $recipient); + } + } + } + + /** + * Send an email to watchers with the file info + * @param int $issue_id + * @param int $file_id + */ + public function issue_file($issue_id, $file_id) + { + $f3 = \Base::instance(); + if ($f3->get("mail.from")) { + $log = new \Log("mail.log"); + + // Get issue and comment data + $issue = new \Model\Issue(); + $issue->load($issue_id); + $file = new \Model\Issue\File\Detail(); + $file->load($file_id); + + // This should catch a bug I can't currently find the source of. --Alan + if ($file->issue_id != $issue->id) { + return; + } + + // Get issue parent if set + if ($issue->parent_id) { + $parent = new \Model\Issue(); + $parent->load($issue->parent_id); + $f3->set("parent", $parent); + } + + // Get recipient list and remove current user + $recipients = $this->_issue_watchers($issue_id); + $recipients = array_diff($recipients, [$file->user_email]); + + // Render message body + $f3->set("issue", $issue); + $f3->set("file", $file); + $f3->set("previewText", $file->filename); + $text = $this->_render("notification/file.txt"); + $body = $this->_render("notification/file.html"); + + $subject = "[#{$issue->id}] - {$file->user_name} attached a file to {$issue->name}"; + + // Send to recipients + foreach ($recipients as $recipient) { + $this->utf8mail($recipient, $subject, $body, $text); + $log->write("Sent file notification to: " . $recipient); + } + } + } + + /** + * Send a user a password reset email + * @param int $user_id + * @param string $token + */ + public function user_reset($user_id, $token) + { + $f3 = \Base::instance(); + if ($f3->get("mail.from")) { + $user = new \Model\User(); + $user->load($user_id); + + if (!$user->id) { + throw new \Exception("User does not exist."); + } + + // Render message body + $f3->set("token", $token); + $text = $this->_render("notification/user_reset.txt"); + $body = $this->_render("notification/user_reset.html"); + + // Send email to user + $subject = "Reset your password - " . $f3->get("site.name"); + $this->utf8mail($user->email, $subject, $body, $text); + } + } + + /** + * Send a user an email listing the issues due today and any overdue issues + * @param \Model\User $user + * @param array $due + * @param array $overdue + * @return bool + */ + public function user_due_issues(\Model\User $user, array $due, array $overdue) + { + $f3 = \Base::instance(); + if ($f3->get("mail.from")) { + $f3->set("due", $due); + $f3->set("overdue", $overdue); + $preview = count($due) . " issues due today"; + if ($overdue) { + $preview .= ", " . count($overdue) . " overdue issues"; + } + $f3->set("previewText", $preview); + $subject = "Due Today - " . $f3->get("site.name"); + $text = $this->_render("notification/user_due_issues.txt"); + $body = $this->_render("notification/user_due_issues.html"); + return $this->utf8mail($user->email, $subject, $body, $text); + } + return false; + } + + /** + * Get array of email addresses of all watchers on an issue + * @param int $issue_id + * @return array + */ + protected function _issue_watchers($issue_id) + { + $db = \Base::instance()->get("db.instance"); + $recipients = []; + + // Add issue author and owner + $result = $db->exec("SELECT u.email FROM issue i INNER JOIN `user` u on i.author_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id); + if (!empty($result[0]["email"])) { + $recipients[] = $result[0]["email"]; + } + + + $result = $db->exec("SELECT u.email FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id); + if (!empty($result[0]["email"])) { + $recipients[] = $result[0]["email"]; + } + + // Add whole group + $result = $db->exec("SELECT u.role, u.id FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id); + if ($result && $result[0]["role"] == 'group') { + $group_users = $db->exec("SELECT g.user_email FROM user_group_user g WHERE g.deleted_date IS NULL AND g.group_id = ?", $result[0]["id"]); + foreach ($group_users as $group_user) { + if (!empty($group_user["user_email"])) { + $recipients[] = $group_user["user_email"]; + } + } + } + + // Add watchers + $watchers = $db->exec("SELECT u.email FROM issue_watcher w INNER JOIN `user` u ON w.user_id = u.id WHERE u.deleted_date IS NULL AND issue_id = ?", $issue_id); + foreach ($watchers as $watcher) { + $recipients[] = $watcher["email"]; + } + + // Remove duplicate users + return array_unique($recipients); + } + + /** + * Render a view and return the result + * @param string $file + * @param string $mime + * @param array $hive + * @param integer $ttl + * @return string + */ + protected function _render($file, $mime = "text/html", array $hive = null, $ttl = 0) + { + return \Helper\View::instance()->render($file, $mime, $hive, $ttl); + } } diff --git a/app/helper/plugin.php b/app/helper/plugin.php index 15f9dd98..607e7de7 100644 --- a/app/helper/plugin.php +++ b/app/helper/plugin.php @@ -2,149 +2,150 @@ namespace Helper; -class Plugin extends \Prefab { +class Plugin extends \Prefab +{ + protected $_hooks = []; + protected $_nav = []; + protected $_jsCode = []; + protected $_jsFiles = []; - protected - $_hooks = array(), - $_nav = array(), - $_jsCode = array(), - $_jsFiles = array(); + /** + * Register a hook function + * @param string $hook + * @param callable $action + */ + public function addHook($hook, callable $action) + { + if (isset($this->_hooks[$hook])) { + $this->_hooks[$hook][] = $action; + } else { + $this->_hooks[$hook] = [$action]; + } + } - /** - * Register a hook function - * @param string $hook - * @param callable $action - */ - public function addHook($hook, callable $action) { - if(isset($this->_hooks[$hook])) { - $this->_hooks[$hook][] = $action; - } else { - $this->_hooks[$hook] = array($action); - } - } + /** + * Call registered hook functions, passing data + * @param string $hook + * @param mixed $data + * @return mixed + */ + public function callHook($hook, $data = null) + { + if (empty($this->_hooks[$hook])) { + return $data; + } - /** - * Call registered hook functions, passing data - * @param string $hook - * @param mixed $data - * @return mixed - */ - public function callHook($hook, $data = null) { - if(empty($this->_hooks[$hook])) { - return $data; - } + foreach ($this->_hooks[$hook] as $cb) { + $data = $cb($data); + } + return $data; + } - foreach($this->_hooks[$hook] as $cb) { - $data = $cb($data); - } - return $data; - } + /** + * Add a navigation item + * @param string $href + * @param string $title + * @param string $match + * @param string $location + */ + public function addNavItem($href, $title, $match = null, $location = 'root') + { + $this->_nav[] = [ + "href" => $href, + "title" => $title, + "match" => $match, + "location" => $location, + ]; + } - /** - * Add a navigation item - * @param string $href - * @param string $title - * @param string $match - * @param string $location - */ - public function addNavItem($href, $title, $match = null, $location = 'root') { - $this->_nav[] = array( - "href" => $href, - "title" => $title, - "match" => $match, - "location" => $location - ); - } + /** + * Add JavaScript code + * @param string $code + * @param string $match + */ + public function addJsCode($code, $match = null) + { + $this->_jsCode[] = ["code" => $code, "match" => $match]; + } - /** - * Add JavaScript code - * @param string $code - * @param string $match - */ - public function addJsCode($code, $match = null) { - $this->_jsCode[] = array( - "code" => $code, - "match" => $match - ); - } + /** + * Add a JavaScript file + * @param string $file + * @param string $match + */ + public function addJsFile($file, $match = null) + { + $this->_jsFiles[] = ["file" => $file, "match" => $match]; + } - /** - * Add a JavaScript file - * @param string $file - * @param string $match - */ - public function addJsFile($file, $match = null) { - $this->_jsFiles[] = array( - "file" => $file, - "match" => $match - ); - } + /** + * Get navbar items, optionally setting matching items as active + * @param string $path + * @param string $location + * @return array + */ + public function getNav($path = null, $location = "root") + { + $all = $this->_nav; + $return = []; + foreach ($all as $item) { + if ($item['location'] == $location) { + $return[] = $item + ["active" => ($item["match"] && $path && preg_match($item["match"], $path))]; + } + } + return $return; + } - /** - * Get navbar items, optionally setting matching items as active - * @param string $path - * @param string $location - * @return array - */ - public function getNav($path = null, $location = "root") { - $all = $this->_nav; - $return = array(); - foreach($all as $item) { - if($item['location'] == $location) { - $return[] = $item + array("active" => ($item["match"] && $path && preg_match($item["match"], $path))); - } - } - return $return; - } + /** + * Get a multidimensional array of all nav items by location + * @param string $path + * @return array + */ + public function getAllNavs($path = null) + { + return [ + "root" => $this->getNav($path, "root"), + "user" => $this->getNav($path, "user"), + "new" => $this->getNav($path, "new"), + "browse" => $this->getNav($path, "browse"), + ]; + } - /** - * Get a multidimensional array of all nav items by location - * @param string $path - * @return array - */ - public function getAllNavs($path = null) { - return array( - "root" => $this->getNav($path, "root"), - "user" => $this->getNav($path, "user"), - "new" => $this->getNav($path, "new"), - "browse" => $this->getNav($path, "browse") - ); - } - - /** - * Get an array of matching JS files to include - * @param string $path - * @return array - */ - public function getJsFiles($path = null) { - $return = array(); - foreach($this->_jsFiles as $item) { - if( - !$item['match'] || !$path || - ($item['match'] && $path && preg_match($item['match'], $path)) - ) { - $return[] = $item['file']; - } - } - return $return; - } - - /** - * Get an array of matching JS code to include - * @param string $path - * @return array - */ - public function getJsCode($path = null) { - $return = array(); - foreach($this->_jsCode as $item) { - if( - !$item['match'] || !$path || - ($item['match'] && $path && preg_match($item['match'], $path)) - ) { - $return[] = $item['code']; - } - } - return $return; - } + /** + * Get an array of matching JS files to include + * @param string $path + * @return array + */ + public function getJsFiles($path = null) + { + $return = []; + foreach ($this->_jsFiles as $item) { + if ( + !$item['match'] || !$path || + ($item['match'] && $path && preg_match($item['match'], $path)) + ) { + $return[] = $item['file']; + } + } + return $return; + } + /** + * Get an array of matching JS code to include + * @param string $path + * @return array + */ + public function getJsCode($path = null) + { + $return = []; + foreach ($this->_jsCode as $item) { + if ( + !$item['match'] || !$path || + ($item['match'] && $path && preg_match($item['match'], $path)) + ) { + $return[] = $item['code']; + } + } + return $return; + } } diff --git a/app/helper/security.php b/app/helper/security.php index c956ea2b..ecc57ee6 100644 --- a/app/helper/security.php +++ b/app/helper/security.php @@ -2,160 +2,158 @@ namespace Helper; -class Security extends \Prefab { - - /** - * Generate a salted SHA1 hash - * @param string $string - * @param string $salt - * @return array|string - */ - public function hash($string, $salt = null) { - if($salt === null) { - $salt = $this->salt(); - return array( - "salt" => $salt, - "hash" => sha1($salt . sha1($string)) - ); - } else { - return sha1($salt . sha1($string)); - } - } - - /** - * Generate a secure salt for hashing - * @return string - */ - public function salt() { - return md5($this->rand_bytes(64)); - } - - /** - * Generate a secure SHA1 salt for hasing - * @return string - */ - public function salt_sha1() { - return sha1($this->rand_bytes(64)); - } - - /** - * Generate a secure SHA-256/384/512 salt - * @param integer $size 256, 384, or 512 - * @return string - */ - public function salt_sha2($size = 256) { - $allSizes = array(256, 384, 512); - if(!in_array($size, $allSizes)) { - throw new Exception("Hash size must be one of: " . implode(", ", $allSizes)); - } - return hash("sha$size", $this->rand_bytes(512), false); - } - - /** - * Encrypt a string with ROT13 - * @param string $string - * @return string - */ - public function rot13($string) { - return str_rot13($string); - } - - /** - * ROT13 equivelant for hexadecimal - * @param string $hex - * @return string - */ - public function rot8($hex) { - return strtr( - strtolower($hex), - array( - "0"=>"8", - "1"=>"9", - "2"=>"a", - "3"=>"b", - "4"=>"c", - "5"=>"d", - "6"=>"e", - "7"=>"f", - "8"=>"0", - "9"=>"1", - "a"=>"2", - "b"=>"3", - "c"=>"4", - "d"=>"5", - "e"=>"6", - "f"=>"7" - ) - ); - } - - /** - * Generate secure random bytes - * @param integer $length - * @return binary - */ - private function rand_bytes($length = 16) { - - // Use OpenSSL cryptography extension if available - if(function_exists("openssl_random_pseudo_bytes")) { - $strong = false; - $rnd = openssl_random_pseudo_bytes($length, $strong); - if($strong === true) { - return $rnd; - } - } - - // Use SHA256 of mt_rand if OpenSSL is not available - $rnd = ""; - for($i = 0; $i < $length; $i++) { - $sha = hash("sha256", mt_rand()); - $char = mt_rand(0, 30); - $rnd .= chr(hexdec($sha[$char] . $sha[$char + 1])); - } - return (binary)$rnd; - } - - /** - * Check if the database is the latest version - * @return bool|string TRUE if up-to-date, next version otherwise. - */ - public function checkDatabaseVersion() { - $f3 = \Base::instance(); - - // Get current version - $version = $f3->get("version"); - if(!$version) { - $result = $f3->get("db.instance")->exec("SELECT value as version FROM config WHERE attribute = 'version'"); - $version = $result[0]["version"]; - } - - // Check available versions - $db_files = scandir("db"); - foreach ($db_files as $file) { - $file = substr($file, 0, -4); - if(version_compare($file, $version) > 0) { - return $file; - } - } - - return true; - } - - /** - * Install latest core database updates - * @param string $version - */ - public function updateDatabase($version) { - $f3 = \Base::instance(); - if(file_exists("db/{$version}.sql")) { - $update_db = file_get_contents("db/{$version}.sql"); - $db = $f3->get("db.instance"); - $db->exec(explode(";", $update_db)); - \Cache::instance()->reset(); - $f3->set("success", " Database updated to version: {$version}"); - } else { - $f3->set("error", " Database file not found for version: {$version}"); - } - } - +class Security extends \Prefab +{ + /** + * Generate a salted SHA1 hash + * @param string $string + * @param string $salt + * @return array|string + */ + public function hash($string, $salt = null) + { + if ($salt === null) { + $salt = $this->salt(); + return [ + "salt" => $salt, + "hash" => sha1($salt . sha1($string)), + ]; + } else { + return sha1($salt . sha1($string)); + } + } + + /** + * Generate a secure salt for hashing + * @return string + */ + public function salt() + { + return md5(random_bytes(64)); + } + + /** + * Generate a secure SHA1 salt for hashing + * @return string + */ + public function salt_sha1() + { + return sha1(random_bytes(64)); + } + + /** + * Generate a secure SHA-256/384/512 salt + * @param integer $size 256, 384, or 512 + * @return string + */ + public function salt_sha2($size = 256) + { + $allSizes = [256, 384, 512]; + if (!in_array($size, $allSizes)) { + throw new \Exception("Hash size must be one of: " . implode(", ", $allSizes)); + } + return hash("sha$size", random_bytes(512), false); + } + + /** + * Generate secure random bytes + * @deprecated + * @param integer $length + * @return string + */ + public function randBytes($length = 16) + { + return random_bytes($length); + } + + /** + * Check if the database is the latest version + * @return bool|string TRUE if up-to-date, next version otherwise. + */ + public function checkDatabaseVersion() + { + $f3 = \Base::instance(); + + // Get current version + $version = $f3->get("version"); + if (!$version) { + $result = $f3->get("db.instance")->exec("SELECT value as version FROM config WHERE attribute = 'version'"); + $version = $result[0]["version"]; + } + + // Check available versions + $db_files = scandir("db"); + foreach ($db_files as $file) { + $file = substr($file, 0, -4); + if (version_compare($file, $version) > 0) { + return $file; + } + } + + return true; + } + + /** + * Install latest core database updates + * @param string $version + */ + public function updateDatabase($version) + { + $f3 = \Base::instance(); + if (file_exists("db/{$version}.sql")) { + $update_db = file_get_contents("db/{$version}.sql"); + $db = $f3->get("db.instance"); + foreach (explode(";", $update_db) as $stmt) { + $db->exec($stmt); + } + \Cache::instance()->reset(); + $f3->set("success", " Database updated to version: {$version}"); + } else { + $f3->set("error", " Database file not found for version: {$version}"); + } + } + + /** + * Initialize a CSRF token + */ + public function initCsrfToken() + { + $f3 = \Base::instance(); + if (!($token = $f3->get('COOKIE.XSRF-TOKEN'))) { + $token = $this->salt_sha2(); + $f3->set('COOKIE.XSRF-TOKEN', $token); + } + $f3->set('csrf_token', $token); + } + + /** + * Validate a CSRF token, exiting if invalid + */ + public function validateCsrfToken() + { + $f3 = \Base::instance(); + $cookieToken = $f3->get('COOKIE.XSRF-TOKEN'); + $requestToken = $f3->get('POST.csrf-token'); + if (!$requestToken) { + $requestToken = $f3->get('HEADERS.X-Xsrf-Token'); + } + if (!$cookieToken || !$requestToken || !hash_equals($cookieToken, $requestToken)) { + $f3->error(400, 'Invalid CSRF token'); + exit; + } + } + + /** + * Check if two hashes are equal, safe against timing attacks + * + * @deprecated Use the native PHP implementation instead. + * + * @param string $str1 + * @param string $str2 + * @return boolean + */ + public function hashEquals($str1, $str2) + { + return hash_equals($str1, $str2); + } } diff --git a/app/helper/template.php b/app/helper/template.php new file mode 100644 index 00000000..d6d12112 --- /dev/null +++ b/app/helper/template.php @@ -0,0 +1,11 @@ +esc($csrf_token) ?>" />'; + } +} diff --git a/app/helper/update.php b/app/helper/update.php index f2ed9b17..e6143290 100644 --- a/app/helper/update.php +++ b/app/helper/update.php @@ -2,176 +2,193 @@ namespace Helper; -class Update extends \Prefab { - - protected $cache = array(); - - /** - * Generate human-readable data for issue updates - * @param string $field - * @param string|int $old_val - * @param string|int $new_val - * @return array - */ - function humanReadableValues($field, $old_val, $new_val) { - $f3 = \Base::instance(); - - // Generate human readable values - $func = $f3->camelcase("convert_$field"); - if(is_callable(array($this, $func))) { - if($old_val !== null && $old_val !== '') { - $old_val = call_user_func_array( - array($this, $func), array($old_val)); - } - if($new_val !== null && $new_val !== '') { - $new_val = call_user_func_array( - array($this, $func), array($new_val)); - } - } - - // Generate human readable field name - $name = $f3->get("dict.cols." . $field); - if($name === null) { - $name = ucwords(str_replace( - array("_", " id"), array(" ", ""), $field)); - } - - return array("field" => $name, "old" => $old_val, "new" => $new_val); - } - - /** - * Convert a user ID to a user name - * @param int $id - * @return string - */ - public function convertUserId($id) { - if(isset($this->cache['user.' . $id])) { - $user = $this->cache['user.' . $id]; - } else { - $user = new \Model\User; - $user->load($id); - $this->cache['user.' . $id] = $user; - } - return $user->name; - } - - /** - * Convert an owner user ID to a name - * @param int $id - * @return string - */ - public function convertOwnerId($id) { - return $this->convertUserId($id); - } - - /** - * Convert an author user ID to a name - * @param int $id - * @return string - */ - public function convertAuthorId($id) { - return $this->convertUserId($id); - } - - /** - * Convert a status ID to a name - * @param int $id - * @return string - */ - public function convertStatus($id) { - if(isset($this->cache['status.' . $id])) { - $status = $this->cache['status.' . $id]; - } else { - $status = new \Model\Issue\Status; - $status->load($id); - $this->cache['status.' . $id] = $status; - } - return $status->name; - } - - /** - * Convert a priority ID to a name - * @param int $value - * @return string - */ - public function convertPriority($value) { - if(isset($this->cache['priority.' . $value])) { - $priority = $this->cache['priority.' . $value]; - } else { - $priority = new \Model\Issue\Priority; - $priority->load(array("value = ?", $value)); - $this->cache['priority.' . $value] = $priority; - } - return $priority->name; - } - - /** - * Convert an issue ID to a name - * @param int $id - * @return string - */ - public function convertIssueId($id) { - if(isset($this->cache['issue.' . $id])) { - $issue = $this->cache['issue.' . $id]; - } else { - $issue = new \Model\Issue; - $issue->load($id); - $this->cache['issue.' . $id] = $issue; - } - return $issue->name; - } - - /** - * Convert a parent issue ID to a name - * @param int $id - * @param string - */ - public function convertParentId($id) { - return $this->convertIssueId($id); - } - - /** - * Convert a sprint ID to a name/date - * @param int $id - * @return string - */ - public function convertSprintId($id) { - if(isset($this->cache['sprint.' . $id])) { - $sprint = $this->cache['sprint.' . $id]; - } else { - $sprint = new \Model\Sprint; - $sprint->load($id); - $this->cache['sprint.' . $id] = $sprint; - } - return $sprint->name . " - " . - date('n/j', strtotime($sprint->start_date)) . "-" . - date('n/j', strtotime($sprint->end_date)); - } - - /** - * Convert a sprint ID to a name/date - * @param int $id - * @return string - */ - public function convertTypeId($id) { - if(isset($this->cache['type.' . $id])) { - $type = $this->cache['type.' . $id]; - } else { - $type = new \Model\Issue\Type; - $type->load($id); - $this->cache['type.' . $id] = $type; - } - return $type->name; - } - - /** - * Convert MySQL datetime to formatted local time - * @param string $date - * @return string - */ - public function convertClosedDate($date) { - $time = View::instance()->utc2local(strtotime($date)); - return date("D, M j, Y g:ia", $time); - } - +class Update extends \Prefab +{ + protected $cache = []; + + /** + * Generate human-readable data for issue updates + * @param string $field + * @param string|int $old_val + * @param string|int $new_val + * @return array + */ + public function humanReadableValues($field, $old_val, $new_val) + { + $f3 = \Base::instance(); + + // Generate human readable values + $func = $f3->camelcase("convert_$field"); + if (is_callable([$this, $func])) { + if ($old_val !== null && $old_val !== '') { + $old_val = call_user_func_array( + [$this, $func], + [$old_val] + ); + } + if ($new_val !== null && $new_val !== '') { + $new_val = call_user_func_array( + [$this, $func], + [$new_val] + ); + } + } + + // Generate human readable field name + $name = $f3->get("dict.cols." . $field); + if ($name === null) { + $name = ucwords(str_replace( + ["_", " id"], + [" ", ""], + $field + )); + } + + return ["field" => $name, "old" => $old_val, "new" => $new_val]; + } + + /** + * Convert a user ID to a user name + * @param int $id + * @return string + */ + public function convertUserId($id) + { + if (isset($this->cache['user.' . $id])) { + $user = $this->cache['user.' . $id]; + } else { + $user = new \Model\User(); + $user->load($id); + $this->cache['user.' . $id] = $user; + } + return $user->name; + } + + /** + * Convert an owner user ID to a name + * @param int $id + * @return string + */ + public function convertOwnerId($id) + { + return $this->convertUserId($id); + } + + /** + * Convert an author user ID to a name + * @param int $id + * @return string + */ + public function convertAuthorId($id) + { + return $this->convertUserId($id); + } + + /** + * Convert a status ID to a name + * @param int $id + * @return string + */ + public function convertStatus($id) + { + if (isset($this->cache['status.' . $id])) { + $status = $this->cache['status.' . $id]; + } else { + $status = new \Model\Issue\Status(); + $status->load($id); + $this->cache['status.' . $id] = $status; + } + return $status->name; + } + + /** + * Convert a priority ID to a name + * @param int $value + * @return string + */ + public function convertPriority($value) + { + if (isset($this->cache['priority.' . $value])) { + $priority = $this->cache['priority.' . $value]; + } else { + $priority = new \Model\Issue\Priority(); + $priority->load(["value = ?", $value]); + $this->cache['priority.' . $value] = $priority; + } + return $priority->name; + } + + /** + * Convert an issue ID to a name + * @param int $id + * @return string + */ + public function convertIssueId($id) + { + if (isset($this->cache['issue.' . $id])) { + $issue = $this->cache['issue.' . $id]; + } else { + $issue = new \Model\Issue(); + $issue->load($id); + $this->cache['issue.' . $id] = $issue; + } + return $issue->name; + } + + /** + * Convert a parent issue ID to a name + * @param int $id + * @param string + */ + public function convertParentId($id) + { + return $this->convertIssueId($id); + } + + /** + * Convert a sprint ID to a name/date + * @param int $id + * @return string + */ + public function convertSprintId($id) + { + if (isset($this->cache['sprint.' . $id])) { + $sprint = $this->cache['sprint.' . $id]; + } else { + $sprint = new \Model\Sprint(); + $sprint->load($id); + $this->cache['sprint.' . $id] = $sprint; + } + return $sprint->name . " - " . + date('n/j', strtotime($sprint->start_date)) . "-" . + date('n/j', strtotime($sprint->end_date)); + } + + /** + * Convert a sprint ID to a name/date + * @param int $id + * @return string + */ + public function convertTypeId($id) + { + if (isset($this->cache['type.' . $id])) { + $type = $this->cache['type.' . $id]; + } else { + $type = new \Model\Issue\Type(); + $type->load($id); + $this->cache['type.' . $id] = $type; + } + return $type->name; + } + + /** + * Convert MySQL datetime to formatted local time + * @param string $date + * @return string + */ + public function convertClosedDate($date) + { + $time = View::instance()->utc2local(strtotime($date)); + return date("D, M j, Y g:ia", $time); + } } diff --git a/app/helper/view.php b/app/helper/view.php index af6b5721..983aa81c 100644 --- a/app/helper/view.php +++ b/app/helper/view.php @@ -2,347 +2,347 @@ namespace Helper; -class View extends \Template { - - public function __construct() { - - // Register filters - $this->filter('parseText','$this->parseText'); - $this->filter('formatFilesize','$this->formatFilesize'); - - parent::__construct(); - } - - /** - * Convert Textile or Markdown to HTML, adding hashtags - * @param string $str - * @param array $options - * @param int $ttl - * @return string - */ - public function parseText($str, $options = array(), $ttl = null) { - if($options === null) { - $options = array(); - } - $options = $options + \Base::instance()->get("parse"); - - // Check for cached value if $ttl is set - if($ttl !== null) { - $cache = \Cache::instance(); - $hash = sha1($str . json_encode($options)); - - // Return value if cached - if(($str = $cache->get("$hash.tex")) !== false) { - return $str; - } - } - - // Pass to any plugin hooks - $str = \Helper\Plugin::instance()->callHook("text.parse.before", $str); - - // Run through the parsers based on $options - if($options["ids"]) { - $str = $this->_parseIds($str); - } - if($options["hashtags"]) { - $str = $this->_parseHashtags($str); - } - if($options["markdown"]) { - $str = $this->_parseMarkdown($str); - } - if($options["textile"]) { - if($options["markdown"]) { - // Yes, this is hacky. Please open an issue on GitHub if you - // know of a better way of supporting Markdown and Textile :) - $str = html_entity_decode($str); - $str = preg_replace('/^

|<\/p>$/m', "\n", $str); - } - $str = $this->_parseTextile($str); - } - if($options["emoticons"]) { - $str = $this->_parseEmoticons($str); - } - if($options["urls"]) { - $str = $this->_parseUrls($str); - } - - // Pass to any plugin hooks - $str = \Helper\Plugin::instance()->callHook("text.parse.after", $str); - - // Cache the value if $ttl is set - if($ttl !== null) { - $cache->set("$hash.tex", $str, $ttl); - } - - return $str; - } - - /** - * Replaces IDs with links to their corresponding issues - * @param string $str - * @return string - */ - protected function _parseIds($str) { - $base = \Base::instance()->get("BASE"); - - // Find all IDs - $count = preg_match_all("/(?<=[^a-z\\/&]#|^#)[0-9]+(?=[^a-z\\/]|$)/i", $str, $matches); - if(!$count) { - return $str; - } - - // Load IDs - $ids = array(); - foreach($matches[0] as $match) { - $ids[] = $match; - } - $idsStr = implode(",", array_unique($ids)); - $issue = new \Model\Issue; - $issues = $issue->find(array("id IN ($idsStr)")); - - return preg_replace_callback("/(?<=[^a-z\\/&]|^)#[0-9]+(?=[^a-z\\/]|$)/i", function($matches) use($base, $issues) { - $id = ltrim($matches[0], "#"); - foreach($issues as $i) { - if($i->id == $id) { - $issue = $i; - } - } - if($issue) { - if($issue->deleted_date) { - $f3 = \Base::instance(); - if($f3->get("user.role") == "admin" || $f3->get("user.rank") >= \Model\User::RANK_MANAGER || $f3->get("user.id") == $issue->author_id) { - return "#$id – " . htmlspecialchars($issue->name) . ""; - } else { - return "#$id"; - } - } - return "#$id – " . htmlspecialchars($issue->name) . ""; - } - return "#$id"; - }, $str); - } - - /** - * Replaces hashtags with links to their corresponding tag pages - * @param string $str - * @return string - */ - protected function _parseHashtags($str) { - return preg_replace_callback("/(?<=[^a-z\\/&]|^)#([a-z][a-z0-9_-]*[a-z0-9]+)(?=[^a-z\\/]|$)/i", function($matches) { - $url = \Base::instance()->get("site.url"); - $tag = preg_replace("/[_-]+/", "-", $matches[1]); - return "#$tag"; - }, $str); - } - - /** - * Replaces URLs with links - * @param string $str - * @return string - */ - protected function _parseUrls($str) { - $str = ' ' . $str; - - // In testing, using arrays here was found to be faster - $str = preg_replace_callback('#([\s>])([\w]+?://[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is', function($matches) { - $ret = ''; - $url = $matches[2]; - - if(empty($url)) - return $matches[0]; - // removed trailing [.,;:] from URL - if(in_array(substr($url,-1),array('.',',',';',':')) === true) { - $ret = substr($url,-1); - $url = substr($url,0,strlen($url)-1); - } - return $matches[1] . "$url".$ret; - }, $str); - - $str = preg_replace_callback('#([\s>])((www|ftp)\.[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is', function($m) { - $s = ''; - $d = $m[2]; - - if (empty($d)) - return $m[0]; - - // removed trailing [,;:] from URL - if(in_array(substr($d,-1),array('.',',',';',':')) === true) { - $s = substr($d,-1); - $d = substr($d,0,strlen($d)-1); - } - return $m[1] . "$d".$s; - }, $str); - - $str = preg_replace_callback('#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})#i', function($m) { - $email = $m[2].'@'.$m[3]; - return $m[1]."$email"; - }, $str); - - // This one is not in an array because we need it to run last, for cleanup of accidental links within links - $str = preg_replace("#(]+?>|>))]+?>([^>]+?)#i", "$1$3",$str); - $str = trim($str); - - return $str; - } - - /** - * Replaces text emoticons with webfont versions - * @param string $str - * @return string - */ - protected function _parseEmoticons($str) { - return preg_replace_callback("/([^a-z\\/&]|\\>|^)(3|>)?[:;8B][)(PDOoSs|\/\\\]([^a-z\\/]|\\<|$)/", function($matches) { - $i = ""; - switch (trim($matches[0], "<> ")) { - case ":)": - $i = "smiley"; - break; - case ";)": - $i = "wink"; - break; - case ":(": - $i = "sad"; - break; - case ">:(": - $i = "angry"; - break; - case "8)": - case "B)": - $i = "cool"; - break; - case "3:)": - case ">:)": - $i = "evil"; - break; - case ":D": - $i = "happy"; - break; - case ":P": - $i = "tongue"; - break; - case ":o": - case ":O": - $i = "shocked"; - break; - case ":s": - case ":S": - $i = "confused"; - break; - case ":|": - $i = "neutral"; - break; - case ":/": - case ":\\": - $i = "wondering"; - break; - } - if($i) { - $f3 = \Base::instance(); - $theme = $f3->get("user.theme"); - if(!$theme) { - $theme = $f3->get("site.theme"); - } - if(preg_match("/slate|geo|dark|cyborg/i", $theme)) { - $i .= "2"; - } - return $matches[1] . "" . $matches[count($matches) - 1]; - } else { - return $matches[0]; - } - }, $str); - } - - /** - * Passes a string through the Textile parser - * @param string $str - * @return string - */ - protected function _parseTextile($str) { - $tex = new \Textile\Parser('html5'); - $tex->setDimensionlessImages(true); - return $tex->parse($str); - } - - /** - * Passes a string through the Markdown parser - * @param string $str - * @return string - */ - protected function _parseMarkdown($str) { - $mkd = new \Parsedown(); - $mkd->setUrlsLinked(false); - return $mkd->text($str); - } - - /** - * Get a human-readable file size - * @param int $filesize - * @return string - */ - public function formatFilesize($filesize) { - if($filesize > 1073741824) { - return round($filesize / 1073741824, 2) . " GB"; - } elseif($filesize > 1048576) { - return round($filesize / 1048576, 2) . " MB"; - } elseif($filesize > 1024) { - return round($filesize / 1024, 2) . " KB"; - } else { - return $filesize . " bytes"; - } - } - - /** - * Get a Gravatar URL from email address and size, uses global Gravatar configuration - * @param string $email - * @param integer $size - * @return string - */ - function gravatar($email, $size = 80) { - $f3 = \Base::instance(); - $rating = $f3->get("gravatar.rating") ? $f3->get("gravatar.rating") : "pg"; - $default = $f3->get("gravatar.default") ? $f3->get("gravatar.default") : "mm"; - return "//gravatar.com/avatar/" . md5(strtolower($email)) . - "?s=" . intval($size) . - "&d=" . urlencode($default) . - "&r=" . urlencode($rating); - } - - /** - * Convert a UTC timestamp to local time - * @param int $timestamp - * @return int - */ - function utc2local($timestamp = null) { - if($timestamp && !is_numeric($timestamp)) { - $timestamp = @strtotime($timestamp); - } - if(!$timestamp) { - $timestamp = time(); - } - - $f3 = \Base::instance(); - - if($f3->exists("site.timeoffset")) { - $offset = $f3->get("site.timeoffset"); - } else { - $tz = $f3->get("site.timezone"); - $dtzLocal = new \DateTimeZone($tz); - $dtLocal = new \DateTime("now", $dtzLocal); - $offset = $dtzLocal->getOffset($dtLocal); - $f3->set("site.timeoffset", $offset); - } - - return $timestamp + $offset; - } - - /** - * Get the current primary language - * @return string - */ - function lang() { - $f3 = \Base::instance(); - $langs = $f3->split($f3->get("LANGUAGE")); - return isset($langs[0]) ? $langs[0] : $f3->get("FALLBACK"); - } - +class View extends \Template +{ + public function __construct() + { + // Register filters + $this->filter('parseText', '$this->parseText'); + $this->filter('formatFilesize', '$this->formatFilesize'); + + parent::__construct(); + } + + /** + * Convert Textile or Markdown to HTML, adding hashtags + * @param string $str + * @param array $options + * @param int $ttl + * @return string + */ + public function parseText($str, $options = [], $ttl = null) + { + if ($str === null || $str === '') { + return ''; + } + if ($options === null) { + $options = []; + } + $options = $options + \Base::instance()->get("parse"); + + // Check for cached value if $ttl is set + $cache = \Cache::instance(); + $hash = null; + if ($ttl !== null) { + $hash = sha1($str . json_encode($options, JSON_THROW_ON_ERROR)); + + // Return value if cached + if (($str = $cache->get("$hash.tex")) !== false) { + return $str; + } + } + + // Pass to any plugin hooks + $str = \Helper\Plugin::instance()->callHook("text.parse.before", $str); + + // Run through the parsers based on $options + if ($options["ids"]) { + $str = $this->_parseIds($str); + } + if ($options["hashtags"]) { + $str = $this->_parseHashtags($str); + } + if ($options["emoticons"]) { + $str = $this->_parseEmoticons($str); + } + if ($options["markdown"]) { + $str = $this->_parseMarkdown($str); + } + if ($options["textile"]) { + $escape = true; + if ($options["markdown"]) { + // Yes, this is hacky. Please open an issue on GitHub if you + // know of a better way of supporting Markdown and Textile :) + $str = html_entity_decode($str); + $str = preg_replace('/^

|<\/p>$/m', "\n", $str); + $escape = false; + } + $str = $this->_parseTextile($str, $escape); + } + if (!$options["markdown"] && !$options['textile']) { + $str = nl2br(\Base::instance()->encode($str), false); + } + if ($options["urls"]) { + $str = $this->_parseUrls($str); + } + + // Simplistic XSS protection + $antiXss = new \voku\helper\AntiXSS(); + $str = $antiXss->xss_clean($str); + + // Pass to any plugin hooks + $str = \Helper\Plugin::instance()->callHook("text.parse.after", $str); + + // Cache the value if $ttl is set + if ($ttl !== null) { + $cache->set("$hash.tex", $str, $ttl); + } + + return $str; + } + + /** + * Replaces IDs with links to their corresponding issues + * @param string $str + * @return string + */ + protected function _parseIds($str) + { + $url = \Base::instance()->get("site.url"); + + // Find all IDs + $count = preg_match_all("/(?<=[^a-z\\/&]#|^#)[0-9]+(?=[^a-z\\/]|$)/i", $str, $matches); + if (!$count) { + return $str; + } + + // Load IDs + $ids = []; + foreach ($matches[0] as $match) { + $ids[] = $match; + } + $idsStr = implode(",", array_unique($ids)); + $issue = new \Model\Issue(); + $issues = $issue->find(["id IN ($idsStr)"]); + + return preg_replace_callback("/(?<=[^a-z\\/&]|^)#[0-9]+(?=[^a-z\\/]|$)/i", function ($matches) use ($url, $issues) { + $issue = null; + $id = ltrim($matches[0], "#"); + foreach ($issues as $i) { + if ($i->id == $id) { + $issue = $i; + } + } + if ($issue) { + if ($issue->deleted_date) { + $f3 = \Base::instance(); + if ($f3->get("user.role") == "admin" || $f3->get("user.rank") >= \Model\User::RANK_MANAGER || $f3->get("user.id") == $issue->author_id) { + return "#$id – " . htmlspecialchars($issue->name) . ""; + } else { + return "#$id"; + } + } + return "#$id – " . htmlspecialchars($issue->name) . ""; + } + return "#$id"; + }, $str); + } + + /** + * Replaces hashtags with links to their corresponding tag pages + * @param string $str + * @return string + */ + protected function _parseHashtags($str) + { + return preg_replace_callback("/(?<=[^a-z\\/&]|^)#([a-z][a-z0-9_-]*[a-z0-9]+)(?=[^a-z\\/]|$)/i", function ($matches) { + $url = \Base::instance()->get("site.url"); + $tag = preg_replace("/[_-]+/", "-", $matches[1]); + return "#$tag"; + }, $str); + } + + /** + * Replaces URLs with links + * @param string $str + * @return string + */ + protected function _parseUrls($str) + { + $str = ' ' . $str; + + // In testing, using arrays here was found to be faster + $str = preg_replace_callback('#([\s>])([\w]+?://[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is', function ($matches) { + $ret = ''; + $url = $matches[2]; + + if (empty($url)) { + return $matches[0]; + } + // removed trailing [.,;:] from URL + if (in_array(substr($url, -1), ['.', ',', ';', ':']) === true) { + $ret = substr($url, -1); + $url = substr($url, 0, strlen($url) - 1); + } + return $matches[1] . "$url" . $ret; + }, $str); + + $str = preg_replace_callback('#([\s>])((www|ftp)\.[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is', function ($m) { + $s = ''; + $d = $m[2]; + + if (empty($d)) { + return $m[0]; + } + + // removed trailing [,;:] from URL + if (in_array(substr($d, -1), ['.', ',', ';', ':']) === true) { + $s = substr($d, -1); + $d = substr($d, 0, strlen($d) - 1); + } + return $m[1] . "$d" . $s; + }, $str); + + $str = preg_replace_callback('#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})#i', function ($m) { + $email = $m[2] . '@' . $m[3]; + return $m[1] . "$email"; + }, $str); + + // This one is not in an array because we need it to run last, for cleanup of accidental links within links + $str = preg_replace("#(]+?>|>))]+?>([^>]+?)#i", "$1$3", $str); + $str = trim($str); + + return $str; + } + + /** + * Replaces text emoticons with Emoji versions + * @param string $str + * @return string + */ + protected function _parseEmoticons($str) + { + // Custom Emoji map, based on UTF::emojify + $map = [ + ':(' => "\xF0\x9F\x99\x81", // frown + ':)' => "\xF0\x9F\x99\x82", // smile + '<3' => "\xE2\x9D\xA4\xEF\xB8\x8F", // heart + ':D' => "\xF0\x9F\x98\x83", // grin + 'XD' => "\xF0\x9F\x98\x86", // laugh + ';)' => "\xF0\x9F\x98\x89", // wink + ':P' => "\xF0\x9F\x98\x8B", // tongue + ':,' => "\xF0\x9F\x98\x8F", // think + ':/' => "\xF0\x9F\x98\xA3", // skeptic + '8O' => "\xF0\x9F\x98\xB2", // oops + ]; + + $match = implode('|', array_map(fn ($str) => preg_quote($str, '/'), array_keys($map))); + $regex = "/([^a-z\\/&]|^)($match)([^a-z\\/]|$)/m"; + + return preg_replace_callback($regex, fn ($match) => $match[1] . $map[$match[2]] . $match[3], $str); + } + + /** + * Passes a string through the Textile parser + * @param string $str + * @return string + */ + protected function _parseTextile($str, $escape = true) + { + error_reporting(E_ALL ^ E_DEPRECATED); // Temporarily ignore deprecations because this lib is incompatible + $tex = new \Netcarver\Textile\Parser('html5'); + $tex->setDimensionlessImages(true); + $tex->setRestricted($escape); + return $tex->parse($str); + } + + /** + * Passes a string through the Markdown parser + * @param string $str + * @return string + */ + protected function _parseMarkdown($str, $escape = true) + { + $mkd = new \Parsedown(); + $mkd->setUrlsLinked(false); + $mkd->setMarkupEscaped($escape); + return $mkd->text($str); + } + + /** + * Get a human-readable file size + * @param int $filesize + * @return string + */ + public function formatFilesize($filesize) + { + if ($filesize > 1_073_741_824) { + return round($filesize / 1_073_741_824, 2) . " GB"; + } elseif ($filesize > 1_048_576) { + return round($filesize / 1_048_576, 2) . " MB"; + } elseif ($filesize > 1024) { + return round($filesize / 1024, 2) . " KB"; + } else { + return $filesize . " bytes"; + } + } + + /** + * Get a Gravatar URL from email address and size, uses global Gravatar configuration + * @param string $email + * @param integer $size + * @return string + */ + public function gravatar($email, $size = 80) + { + $f3 = \Base::instance(); + $rating = $f3->get("gravatar.rating") ?: "pg"; + $default = $f3->get("gravatar.default") ?: "mm"; + return "https://gravatar.com/avatar/" . md5(strtolower($email ?? '')) . + "?s=" . intval($size) . + "&d=" . urlencode($default) . + "&r=" . urlencode($rating); + } + + /** + * Get UTC time offset in seconds + * + * @return int + */ + public function timeoffset() + { + $f3 = \Base::instance(); + + if ($f3->exists("site.timeoffset")) { + return $f3->get("site.timeoffset"); + } else { + $tz = $f3->get("site.timezone"); + $dtzLocal = new \DateTimeZone($tz); + $dtLocal = new \DateTime("now", $dtzLocal); + $offset = $dtzLocal->getOffset($dtLocal); + $f3->set("site.timeoffset", $offset); + } + + return $offset; + } + + /** + * Convert a UTC timestamp to local time + * @param int|string $timestamp + * @return int + */ + public function utc2local($timestamp = null) + { + if ($timestamp && !is_numeric($timestamp)) { + $timestamp = strtotime($timestamp); + } + if (!$timestamp) { + $timestamp = time(); + } + + $offset = $this->timeoffset(); + + return $timestamp + $offset; + } + + /** + * Get the current primary language + * @return string + */ + public function lang() + { + $f3 = \Base::instance(); + $langs = $f3->split($f3->get("LANGUAGE")); + return $langs[0] ?? $f3->get("FALLBACK"); + } } diff --git a/app/model.php b/app/model.php index cd782662..266d1306 100644 --- a/app/model.php +++ b/app/model.php @@ -1,148 +1,154 @@ _table_name)) { - if(empty($table_name)) { - $f3->error(500, "Model instance does not have a table name specified."); - } else { - $this->table_name = $table_name; - } - } - - parent::__construct($f3->get("db.instance"), $this->_table_name, null, $f3->get("cache_expire.db")); - return $this; - } - - /** - * Create and save a new item - * @param array $data - * @return Comment - */ - public static function create(array $data) { - $item = new static(); - - // Check required fields - foreach(self::$requiredFields as $field) { - if(!isset($data[$field])) { - throw new Exception("Required field $field not specified."); - } - } - - // Set field values - foreach($data as $key => $val) { - if($item->exists($key)) { - if(empty($val)) { - $val = null; - } - $item->set($key, $val); - } - } - - // Set auto values if they exist - if($item->exists("created_date") && !isset($data["created_date"])) { - $item->set("created_date", date("Y-m-d H:i:s")); - } - - $item->save(); - return $item; - } - - /** - * Save model, triggering plugin hooks and setting created_date - * @return mixed - */ - function save() { - // Ensure created_date is set if possible - if(!$this->query && array_key_exists("created_date", $this->fields) && !$this->get("created_date")) { - $this->set("created_date", date("Y-m-d H:i:s")); - } - - // Call before_save hooks - $hookName = str_replace("\\", "/", strtolower(get_class($this))); - \Helper\Plugin::instance()->callHook("model.before_save", $this); - \Helper\Plugin::instance()->callHook($hookName.".before_save", $this); - - // Save object - $result = parent::save(); - - // Call after save hooks - \Helper\Plugin::instance()->callHook("model.after_save", $this); - \Helper\Plugin::instance()->callHook($hookName.".after_save", $this); - - return $result; - } - - /** - * Safely delete object if possible, if not, erase the record. - * @return mixed - */ - function delete() { - if(array_key_exists("deleted_date", $this->fields)) { - $this->deleted_date = date("Y-m-d H:i:s"); - return $this->save(); - } else { - return $this->erase(); - } - } - - /** - * Load by ID directly if a string is passed - * @param int|array $filter - * @param array $options - * @param integer $ttl - * @return mixed - */ - function load($filter=NULL, array $options=NULL, $ttl=0) { - if(is_numeric($filter)) { - return parent::load(array("id = ?", $filter), $options, $ttl); - } elseif(is_array($filter)) { - return parent::load($filter, $options, $ttl); - } - throw new Exception("\$filter must be either int or array."); - } - - /** - * Takes two dates and creates an inclusive array of the dates between - * the from and to dates in YYYY-MM-DD format. - * @param string $strDateFrom - * @param string $strDateTo - * @return array - */ - protected function _createDateRangeArray($dateFrom, $dateTo) { - $range = array(); - - $from = strtotime($dateFrom); - $to = strtotime($dateTo); - - if ($to >= $from) { - $range[] = date('Y-m-d', $from); // first entry - while ($from < $to) { - $from += 86400; // add 24 hours - $range[] = date('Y-m-d', $from); - } - } - - return $range; - } - - /** - * Get most recent value of field - * @param string $key - * @return mixed - */ - protected function _getPrev($key) { - if(!$this->query) { - return null; - } - $prev_fields = $this->query[count($this->query) - 1]->fields; - return array_key_exists($key, $prev_fields) ? $prev_fields[$key]["value"] : null; - } - +abstract class Model extends \DB\SQL\Mapper +{ + protected $fields = []; + protected static $requiredFields = []; + + public function __construct($table_name = null) + { + $f3 = \Base::instance(); + + if (empty($this->_table_name)) { + if (empty($table_name)) { + $f3->error(500, "Model instance does not have a table name specified."); + } else { + $this->table_name = $table_name; + } + } + + parent::__construct($f3->get("db.instance"), $this->_table_name, null, $f3->get("cache_expire.db")); + return $this; + } + + /** + * Create and save a new item + * @param array $data + * @return Comment + */ + public static function create(array $data) + { + $item = new static(); + + // Check required fields + foreach (self::$requiredFields as $field) { + if (!isset($data[$field])) { + throw new Exception("Required field $field not specified."); + } + } + + // Set field values + foreach ($data as $key => $val) { + if ($item->exists($key)) { + if (empty($val) && ($val != 0 || $val === '')) { + $val = null; + } + $item->set($key, $val); + } + } + + // Set auto values if they exist + if ($item->exists("created_date") && !isset($data["created_date"])) { + $item->set("created_date", date("Y-m-d H:i:s")); + } + + $item->save(); + return $item; + } + + /** + * Save model, triggering plugin hooks and setting created_date + * @return mixed + */ + public function save() + { + // Ensure created_date is set if possible + if (!$this->query && array_key_exists("created_date", $this->fields) && !$this->get("created_date")) { + $this->set("created_date", date("Y-m-d H:i:s")); + } + + // Call before_save hooks + $hookName = str_replace("\\", "/", strtolower(get_class($this))); + \Helper\Plugin::instance()->callHook("model.before_save", $this); + \Helper\Plugin::instance()->callHook($hookName . ".before_save", $this); + + // Save object + $result = parent::save(); + + // Call after save hooks + \Helper\Plugin::instance()->callHook("model.after_save", $this); + \Helper\Plugin::instance()->callHook($hookName . ".after_save", $this); + + return $result; + } + + /** + * Safely delete object if possible, if not, erase the record. + * @return mixed + */ + public function delete() + { + if (array_key_exists("deleted_date", $this->fields)) { + $this->deleted_date = date("Y-m-d H:i:s"); + return $this->save(); + } else { + return $this->erase(); + } + } + + /** + * Load by ID directly if a string is passed + * @param int|array $filter + * @param array $options + * @param integer $ttl + * @return mixed + */ + public function load($filter = null, array $options = null, $ttl = 0) + { + if (is_numeric($filter)) { + return parent::load(["id = ?", $filter], $options, $ttl); + } elseif (is_array($filter)) { + return parent::load($filter, $options, $ttl); + } + throw new Exception("\$filter must be either int or array."); + } + + /** + * Takes two dates and creates an inclusive array of the dates between + * the from and to dates in YYYY-MM-DD format. + * @param string $strDateFrom + * @param string $strDateTo + * @return array + */ + protected function _createDateRangeArray($dateFrom, $dateTo) + { + $range = []; + + $from = strtotime($dateFrom); + $to = strtotime($dateTo); + + if ($to >= $from) { + $range[] = date('Y-m-d', $from); // first entry + while ($from < $to) { + $from += 86400; // add 24 hours + $range[] = date('Y-m-d', $from); + } + } + + return $range; + } + + /** + * Get most recent value of field + * @param string $key + * @return mixed + */ + protected function _getPrev($key) + { + if (!$this->query) { + return null; + } + $prev_fields = $this->query[count($this->query) - 1]->fields; + return array_key_exists($key, $prev_fields) ? $prev_fields[$key]["value"] : null; + } } diff --git a/app/model/attribute.php b/app/model/attribute.php deleted file mode 100644 index f983c397..00000000 --- a/app/model/attribute.php +++ /dev/null @@ -1,18 +0,0 @@ -get("db.instance"); + $result = $db->exec("SELECT attribute,value FROM config"); + $foundAttributes = []; + foreach ($result as $item) { + $foundAttributes[] = $item["attribute"]; + if ($item["attribute"] == 'session_lifetime') { + $f3->set('JAR.expire', $item['value'] + time()); + } + $f3->set($item["attribute"], $item["value"]); + } - /** - * Loads the configuration for the site - */ - public static function loadAll() { - $f3 = \Base::instance(); - $db = $f3->get("db.instance"); - $result = $db->exec("SELECT attribute,value FROM config"); - $foundAttributes = array(); - foreach($result as $item) { - $foundAttributes[] = $item["attribute"]; - $f3->set($item["attribute"], $item["value"]); - } - if(!in_array("site.name", $foundAttributes)) { - self::importAll(); - } - } + // Set some basic config values if they're not already there + if (!in_array("site.theme", $foundAttributes)) { + self::setVal('site.theme', 'css/bootstrap-phproject.css'); + } + if (!in_array("site.name", $foundAttributes)) { + self::importAll(); + } + } - /** - * Imports the settings from config.ini to the database - * - * This will overwrite config.ini with only database connection settings! - */ - public static function importAll() { - $f3 = \Base::instance(); - $root = $f3->get('ROOT').$f3->get('BASE'); + /** + * Imports the settings from config.ini to the database + * + * This will overwrite config.ini with only database connection settings! + * @return void + */ + public static function importAll() + { + $f3 = \Base::instance(); + $root = $f3->get("ROOT") . $f3->get("BASE"); - // Import existing config - $ini = parse_ini_file($root.'/config.ini'); - $ini = $ini + parse_ini_file($root.'/config-base.ini'); - foreach($ini as $key => $val) { - if(substr($key, 0, 3) == 'db.') { - continue; - } - $conf = new Config; - $conf->attribute = $key; - $conf->value = $val; - $conf->save(); - } + // Import existing config + $ini = parse_ini_file($root . "/config.ini"); + $ini = $ini + parse_ini_file($root . "/config-base.ini"); + foreach ($ini as $key => $val) { + if (substr($key, 0, 3) == "db.") { + continue; + } + $conf = new Config(); + $conf->attribute = $key; + $conf->value = $val; + $conf->save(); + } - // Write new config.ini - $data = "[globals]\n"; - $data .= "db.host={$ini['db.host']}\n"; - $data .= "db.user={$ini['db.user']}\n"; - $data .= "db.pass={$ini['db.pass']}\n"; - $data .= "db.name={$ini['db.name']}\n"; - file_put_contents($root.'/config.ini', $data); - } + // Write new config.ini + $data = "[globals]\n"; + $data .= "db.host=\"{$ini['db.host']}\"\n"; + $data .= "db.user=\"{$ini['db.user']}\"\n"; + $data .= "db.pass=\"{$ini['db.pass']}\"\n"; + $data .= "db.name=\"{$ini['db.name']}\"\n"; + file_put_contents($root . "/config.ini", $data); + } - /** - * Set a configuration value - * @param string $key - * @param mixed $value - * @return Config - */ - public static function setVal($key, $value) { - $f3 = \Base::instance(); - $f3->set($key, $value); - $item = new static(); - $item->load(array('attribute = ?', $key)); - $item->attribute = $key; - $item->value = $value; - $item->save(); - return $item; - } + /** + * Convert INI configuration to PHP format + * + * @return void + */ + public static function iniToPhp() + { + $f3 = \Base::instance(); + // Move the config from INI to PHP + $root = $f3->get("ROOT") . $f3->get("BASE"); + $ini = parse_ini_file($root . "/config.ini"); + $data = "set($key, $value); + $item = new static(); + $item->load(['attribute = ?', $key]); + $item->attribute = $key; + $item->value = $value; + $item->save(); + return $item; + } } diff --git a/app/model/custom.php b/app/model/custom.php index ae137423..452b68af 100644 --- a/app/model/custom.php +++ b/app/model/custom.php @@ -2,19 +2,17 @@ namespace Model; -class Custom extends \Model { - - protected $_table_name; - - /** - * Creates a custom model from a specified table name - * @param string $table_name - */ - public function __construct($table_name) { - $this->_table_name = $table_name; - parent::__construct(); - return $this; - } +class Custom extends \Model +{ + protected $_table_name; + /** + * Creates a custom model from a specified table name + * @param string $tableName + */ + public function __construct(string $tableName) + { + $this->_table_name = $tableName; + parent::__construct(); + } } - diff --git a/app/model/issue.php b/app/model/issue.php index b8c2cc96..e3d3ea05 100644 --- a/app/model/issue.php +++ b/app/model/issue.php @@ -10,519 +10,635 @@ * @property int $type_id * @property string $name * @property string $description - * @property int $parent_id + * @property ?int $parent_id * @property int $author_id - * @property int $owner_id + * @property ?int $owner_id * @property int $priority - * @property float $hours_total - * @property float $hours_remaining - * @property float $hours_spent + * @property ?float $hours_total + * @property ?float $hours_remaining + * @property ?float $hours_spent * @property string $created_date - * @property string $closed_date - * @property string $deleted_date - * @property string $start_date - * @property string $due_date - * @property string $repeat_cycle - * @property int $sprint_id + * @property ?string $closed_date + * @property ?string $deleted_date + * @property ?string $start_date + * @property ?string $due_date + * @property ?string $repeat_cycle + * @property ?int $sprint_id */ -class Issue extends \Model { - - protected - $_table_name = "issue", - $_heirarchy = null, - $_children = null; - protected static $requiredFields = array("type_id", "status", "name", "author_id"); - - /** - * Create and save a new issue - * @param array $data - * @param bool $notify - * @return Issue - */ - public static function create(array $data, $notify = true) { - // Normalize data - if (isset($data["hours"])) { - $data["hours_total"] = $data["hours"]; - $data["hours_remaining"] = $data["hours"]; - unset($data["hours"]); - } - if (!empty($data["due_date"])) { - if (!preg_match("/[0-9]{4}(-[0-9]{2}){2}/", $data["due_date"])) { - $data["due_date"] = date("Y-m-d", strtotime($data["due_date"])); - } - if (empty($data["sprint_id"])) { - $sprint = new Sprint(); - $sprint->load(array("DATE(?) BETWEEN start_date AND end_date", $data["due_date"])); - $data["sprint_id"] = $sprint->id; - } - } - if (empty($data["author_id"]) && $user_id = \Base::instance()->get("user.id")) { - $data["author_id"] = $user_id; - } - - // Create issue - /** @var Issue $item */ - $item = parent::create($data); - - // Send creation notifications - if ($notify) { - $notification = \Helper\Notification::instance(); - $notification->issue_create($item->id); - } - - // Return instance - return $item; - } - - /** - * Get complete parent list for issue - * @return array - */ - public function getAncestors() { - if ($this->_heirarchy !== null) { - return $this->_heirarchy; - } - - $issues = array(); - $issues[] = $this; - $issue_ids = array($this->id); - $parent_id = $this->parent_id; - while ($parent_id) { - // Catch infinite loops early on, in case server isn't running linux :) - if (in_array($parent_id, $issue_ids)) { - $f3 = \Base::instance(); - $f3->set("error", "Issue parent tree contains an infinite loop. Issue {$parent_id} is the first point of recursion."); - break; - } - $issue = new Issue(); - $issue->load($parent_id); - if ($issue->id) { - $issues[] = $issue; - $parent_id = $issue->parent_id; - $issue_ids[] = $issue->id; - } else { - // Handle nonexistent issues - $f3 = \Base::instance(); - $f3->set("error", "Issue #{$issue->id} has a parent issue #{$issue->parent_id} that doesn't exist."); - break; - } - } - - $this->_heirarchy = array_reverse($issues); - return $this->_heirarchy; - } - - /** - * Remove messy whitespace from a string - * @param string $string - * @return string - */ - public static function clean($string) { - return preg_replace('/(?:(?:\r\n|\r|\n)\s*){2}/s', "\n\n", str_replace("\r\n", "\n", $string)); - } - - /** - * Delete without sending notification - * @param bool $recursive - * @return Issue - */ - public function delete($recursive = true) { - if (!$this->deleted_date) { - $this->set("deleted_date", date("Y-m-d H:i:s")); - } - if ($recursive) { - $this->_deleteTree(); - } - return $this->save(false); - } - - /** - * Delete a complete issue tree - * @return Issue - */ - protected function _deleteTree() { - $children = $this->find(array("parent_id = ?", $this->id)); - foreach ($children as $child) { - $child->delete(); - } - return $this; - } - - /** - * Restore a deleted issue without notifying - * @param bool $recursive - * @return Issue - */ - public function restore($recursive = true) { - $this->set("deleted_date", null); - if ($recursive) { - $this->_restoreTree(); - } - return $this->save(false); - } - - /** - * Restore a complete issue tree - * @return Issue - */ - protected function _restoreTree() { - $children = $this->find(array("parent_id = ? AND deleted_date IS NOT NULL", $this->id)); - foreach ($children as $child) { - $child->restore(); - } - return $this; - } - - /** - * Repeat an issue by generating a minimal copy and setting new due date - * @param boolean $notify - * @return Issue - */ - public function repeat($notify = true) { - $repeat_issue = new \Model\Issue(); - $repeat_issue->name = $this->name; - $repeat_issue->type_id = $this->type_id; - $repeat_issue->parent_id = $this->parent_id; - $repeat_issue->author_id = $this->author_id; - $repeat_issue->owner_id = $this->owner_id; - $repeat_issue->description = $this->description; - $repeat_issue->priority = $this->priority; - $repeat_issue->repeat_cycle = $this->repeat_cycle; - $repeat_issue->hours_total = $this->hours_total; - $repeat_issue->hours_remaining = $this->hours_total; - $repeat_issue->created_date = date("Y-m-d H:i:s"); - - // Find a due date in the future - switch ($repeat_issue->repeat_cycle) { - case 'daily': - $repeat_issue->start_date = $this->start_date ? date("Y-m-d", strtotime("tomorrow")) : NULL; - $repeat_issue->due_date = date("Y-m-d", strtotime("tomorrow")); - break; - case 'weekly': - $repeat_issue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +1 week")) : NULL; - $repeat_issue->due_date = date("Y-m-d", strtotime($this->due_date . " +1 week")); - break; - case 'monthly': - $repeat_issue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +1 month")) : NULL; - $repeat_issue->due_date = date("Y-m-d", strtotime($this->due_date . " +1 month")); - break; - case 'sprint': - $sprint = new \Model\Sprint(); - $sprint->load(array("start_date > NOW()"), array('order' => 'start_date')); - $repeat_issue->start_date = $this->start_date ? $sprint->start_date : NULL; - $repeat_issue->due_date = $sprint->end_date; - break; - default: - $repeat_issue->repeat_cycle = 'none'; - } - - // If the issue was in a sprint before, put it in a sprint again. - if ($this->sprint_id) { - $sprint = new \Model\Sprint(); - $sprint->load(array("end_date >= ? AND start_date <= ?", $repeat_issue->due_date, $repeat_issue->due_date), array('order' => 'start_date')); - $repeat_issue->sprint_id = $sprint->id; - } - - $repeat_issue->save(); - if($notify) { - $notification = \Helper\Notification::instance(); - $notification->issue_create($repeat_issue->id); - } - return $repeat_issue; - } - - /** - * Log and save an issue update - * @param boolean $notify - * @return Issue\Update - */ - protected function _saveUpdate($notify = true) { - $f3 = \Base::instance(); - - // Ensure issue is not tied to itself as a parent - if ($this->id == $this->parent_id) { - $this->parent_id = $this->_getPrev("parent_id"); - } - - // Log update - $update = new \Model\Issue\Update(); - $update->issue_id = $this->id; - $update->user_id = $f3->get("user.id"); - $update->created_date = date("Y-m-d H:i:s"); - if ($f3->exists("update_comment")) { - $update->comment_id = $f3->get("update_comment")->id; - $update->notify = (int)$notify; - } else { - $update->notify = 0; - } - $update->save(); - - // Set hours_total to the hours_remaining value under certain conditions - if ($this->hours_remaining && !$this->hours_total && - !$this->_getPrev('hours_remaining') && - !$this->_getPrev('hours_total') - ) { - $this->hours_total = $this->hours_remaining; - } - - // Set hours remaining to 0 if the issue has been closed - if ($this->closed_date && $this->hours_remaining) { - $this->hours_remaining = 0; - } - - // Create a new issue if repeating - if ($this->closed_date && $this->repeat_cycle) { - $this->repeat($notify); - $this->repeat_cycle = null; - } - - // Log updated fields - $updated = 0; - $important_changes = 0; - $important_fields = array('status', 'name', 'description', 'owner_id', 'priority', 'due_date'); - foreach ($this->fields as $key => $field) { - if ($field["changed"] && $field["value"] != $this->_getPrev($key)) { - $update_field = new \Model\Issue\Update\Field(); - $update_field->issue_update_id = $update->id; - $update_field->field = $key; - $update_field->old_value = $this->_getPrev($key); - $update_field->new_value = $field["value"]; - $update_field->save(); - $updated++; - if ($key == 'sprint_id') { - $this->resetTaskSprints(); - } - if (in_array($key, $important_fields)) { - $important_changes++; - } - } - } - - // Delete update if no fields were changed - if (!$updated) { - $update->delete(); - } - - // Set notify flag if important changes occurred - if ($notify && $important_changes) { - $update->notify = 1; - $update->save(); - } - - // Send back the update - return $update->id ? $update : false; - } - - /** - * Log issue update, send notifications - * @param boolean $notify - * @return Issue - */ - public function save($notify = true) { - $f3 = \Base::instance(); - - // Catch empty sprint at the lowest level here - if ($this->sprint_id === 0) { - $this->set("sprint_id", null); - } - - // Censor credit card numbers if enabled - if ($f3->get("security.block_ccs")) { - if (preg_match("/([0-9]{3,4}-){3}[0-9]{3,4}/", $this->description)) { - $this->set("description", preg_replace("/([0-9]{3,4}-){3}([0-9]{3,4})/", "************$2", $this->description)); - } - } - - // Make dates correct - if ($this->due_date) { - $this->due_date = date("Y-m-d", strtotime($this->due_date)); - } else { - $this->due_date = null; - } - if ($this->start_date) { - $this->start_date = date("Y-m-d", strtotime($this->start_date)); - } else { - $this->start_date = null; - } - - // Check if updating or inserting - if ($this->query) { - - // Save issue updates and send notifications - $update = $this->_saveUpdate($notify); - $issue = parent::save(); - if ($notify && $update && $update->id && $update->notify) { - $notification = \Helper\Notification::instance(); - $notification->issue_update($this->id, $update->id); - } - - } else { - - // Set closed date if status is closed - if(!$this->closed_date && $this->status) { - $status = new Issue\Status; - $status->load($this->status); - if($status->closed) { - $this->closed_date = date("Y-m-d H:i:s"); - } - } - - } - - $return = empty($issue) ? parent::save() : $issue; - $this->saveTags(); - return $return; - } - - /** - * Finds and saves the current issue's tags - * @return Issue - */ - function saveTags() { - $tag = new \Model\Issue\Tag; - $issue_id = $this->id; - $str = $this->description; - $count = preg_match_all("/(?<=[^a-z\\/&]#|^#)[a-z][a-z0-9_-]*[a-z0-9]+(?=[^a-z\\/]|$)/i", $str, $matches); - if($issue_id) { - $tag->deleteByIssueId($issue_id); - } - if ($count) { - foreach ($matches[0] as $match) { - $tag->reset(); - $tag->tag = preg_replace("/[_-]+/", "-", ltrim($match, "#")); - $tag->issue_id = $issue_id; - $tag->save(); - } - } - return $this; - } - - /** - * Duplicate issue and all sub-issues - * @param bool $recursive - * @return Issue New issue - */ - function duplicate($recursive = true) { - if (!$this->id) { - return false; - } - - $f3 = \Base::instance(); - - $this->copyto("duplicating_issue"); - $f3->clear("duplicating_issue.id"); - $f3->clear("duplicating_issue.due_date"); - - $new_issue = new Issue; - $new_issue->copyfrom("duplicating_issue"); - $new_issue->clear("due_date"); - $new_issue->author_id = $f3->get("user.id"); - $new_issue->created_date = date("Y-m-d H:i:s"); - $new_issue->save(); - - if($recursive) { - // Run the recursive function to duplicate the complete descendant tree - $this->_duplicateTree($this->id, $new_issue->id); - } - - return $new_issue; - } - - /** - * Duplicate a complete issue tree, starting from a duplicated issue created by duplicate() - * @param int $id - * @param int $new_id - * @return Issue $this - */ - protected function _duplicateTree($id, $new_id) { - // Find all child issues - $children = $this->find(array("parent_id = ?", $id)); - if (count($children)) { - $f3 = \Base::instance(); - foreach ($children as $child) { - if (!$child->deleted_date) { - // Duplicate issue - $child->copyto("duplicating_issue"); - $f3->clear("duplicating_issue.id"); - $f3->clear("duplicating_issue.due_date"); - - $new_child = new Issue; - $new_child->copyfrom("duplicating_issue"); - $new_child->clear("id"); - $new_child->clear("due_date"); - $new_child->author_id = $f3->get("user.id"); - $new_child->set("parent_id", $new_id); - $new_child->created_date = date("Y-m-d H:i:s"); - $new_child->save(false); - - // Duplicate issue's children - $this->_duplicateTree($child->id, $new_child->id); - } - } - } - return $this; - } - - /** - * Move all non-project children to same sprint - * @return Issue $this - */ - public function resetTaskSprints($replace_existing = true) { - $f3 = \Base::instance(); - if ($this->sprint_id) { - $query = "UPDATE issue SET sprint_id = :sprint WHERE parent_id = :issue AND type_id != :type"; - if ($replace_existing) { - $query .= " AND sprint_id IS NULL"; - } - $this->db->exec( - $query, - array( - ":sprint" => $this->sprint_id, - ":issue" => $this->id, - ":type" => $f3->get("issue_type.project"), - ) - ); - } - return $this; - } - - /** - * Get children of current issue - * @return array - */ - public function getChildren() { - if ($this->_children !== null) { - return $this->_children; - } - - return $this->_children ?: $this->_children = $this->find(array("parent_id = ? AND deleted_date IS NULL", $this->id)); - } - - /** - * Generate MD5 hashes for each column in a key=>value array - * @return array - */ - public function hashState() { - $result = $this->cast(); - foreach ($result as &$value) { - $value = md5($value); - } - return $result; - } - - /** - * Close the issue - * @return Issue $this - */ - public function close() { - if($this->id && !$this->closed_date) { - $status = new \Model\Issue\Status; - $status->load(array("closed = ?", 1)); - $this->status = $status->id; - $this->closed_date = date("Y-m-d H:i:s"); - $this->save(); - } - return $this; - } - +class Issue extends \Model +{ + protected $_table_name = "issue"; + protected $_heirarchy = null; + protected $_children = null; + protected static $requiredFields = ["type_id", "status", "name", "author_id"]; + + /** + * Create and save a new issue + * @param array $data + * @param bool $notify + * @return Issue + */ + public static function create(array $data, bool $notify = true): Issue + { + // Normalize data + if (isset($data["hours"])) { + $data["hours_total"] = $data["hours"]; + $data["hours_remaining"] = $data["hours"]; + unset($data["hours"]); + } + if (!empty($data["due_date"])) { + if (!preg_match("/[0-9]{4}(-[0-9]{2}){2}/", $data["due_date"])) { + $data["due_date"] = date("Y-m-d", strtotime($data["due_date"])); + } + if (empty($data["sprint_id"]) && !empty($data['due_date_sprint'])) { + $sprint = new Sprint(); + $sprint->load(["DATE(?) BETWEEN start_date AND end_date", $data["due_date"]]); + $data["sprint_id"] = $sprint->id; + } + } + if (empty($data["author_id"]) && $user_id = \Base::instance()->get("user.id")) { + $data["author_id"] = $user_id; + } + + // Create issue + /** @var Issue $item */ + $item = parent::create($data); + + // Send creation notifications + if ($notify) { + $notification = \Helper\Notification::instance(); + $notification->issue_create($item->id); + } + + // Return instance + return $item; + } + + /** + * Get complete parent list for issue + * @return array + */ + public function getAncestors(): array + { + if ($this->_heirarchy !== null) { + return $this->_heirarchy; + } + + $issues = []; + $issues[] = $this; + $issueIds = [$this->id]; + $parentId = $this->parent_id; + while ($parentId) { + // Catch infinite loops early on, in case server isn't running linux :) + if (in_array($parentId, $issueIds)) { + $f3 = \Base::instance(); + $f3->set("error", "Issue parent tree contains an infinite loop. Issue {$parentId} is the first point of recursion."); + break; + } + $issue = new Issue(); + $issue->load($parentId); + if ($issue->id) { + $issues[] = $issue; + $parentId = $issue->parent_id; + $issueIds[] = $issue->id; + } else { + // Handle nonexistent issues + $f3 = \Base::instance(); + $f3->set("error", "Issue #{$issue->id} has a parent issue #{$issue->parent_id} that doesn't exist."); + break; + } + } + + $this->_heirarchy = array_reverse($issues); + return $this->_heirarchy; + } + + /** + * Remove messy whitespace from a string + * @param string $string + * @return string + */ + public static function clean(string $string): string + { + return preg_replace('/(?:(?:\r\n|\r|\n)\s*){2}/s', "\n\n", str_replace("\r\n", "\n", $string)); + } + + /** + * Delete without sending notification + * @param bool $recursive + * @return Issue + */ + public function delete(bool $recursive = true): Issue + { + if (!$this->deleted_date) { + $this->set("deleted_date", date("Y-m-d H:i:s")); + } + if ($recursive) { + $this->_deleteTree(); + } + return $this->save(false); + } + + /** + * Delete a complete issue tree + * @return Issue + */ + protected function _deleteTree(): Issue + { + $children = $this->find(["parent_id = ?", $this->id]); + foreach ($children as $child) { + $child->delete(); + } + return $this; + } + + /** + * Restore a deleted issue without notifying + * @param bool $recursive + * @return Issue + */ + public function restore(bool $recursive = true): Issue + { + $this->set("deleted_date", null); + if ($recursive) { + $this->_restoreTree(); + } + return $this->save(false); + } + + /** + * Restore a complete issue tree + * @return Issue + */ + protected function _restoreTree(): Issue + { + $children = $this->find(["parent_id = ? AND deleted_date IS NOT NULL", $this->id]); + foreach ($children as $child) { + $child->restore(); + } + return $this; + } + + /** + * Repeat an issue by generating a minimal copy and setting new due date + * @param bool $notify + * @return Issue + */ + public function repeat(bool $notify = true): Issue + { + $repeatIssue = new Issue(); + $repeatIssue->name = $this->name; + $repeatIssue->type_id = $this->type_id; + $repeatIssue->parent_id = $this->parent_id; + $repeatIssue->author_id = $this->author_id; + $repeatIssue->owner_id = $this->owner_id; + $repeatIssue->description = $this->description; + $repeatIssue->priority = $this->priority; + $repeatIssue->repeat_cycle = $this->repeat_cycle; + $repeatIssue->hours_total = $this->hours_total; + $repeatIssue->hours_remaining = $this->hours_total; + $repeatIssue->created_date = date("Y-m-d H:i:s"); + + // Find a due date in the future + switch ($repeatIssue->repeat_cycle) { + case 'daily': + $repeatIssue->start_date = $this->start_date ? date("Y-m-d", strtotime("tomorrow")) : null; + $repeatIssue->due_date = date("Y-m-d", strtotime("tomorrow")); + break; + case 'weekly': + $repeatIssue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +1 week")) : null; + $repeatIssue->due_date = date("Y-m-d", strtotime($this->due_date . " +1 week")); + break; + case 'monthly': + $repeatIssue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +1 month")) : null; + $repeatIssue->due_date = date("Y-m-d", strtotime($this->due_date . " +1 month")); + break; + case 'quarterly': + $repeatIssue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +3 months")) : null; + $repeatIssue->due_date = date("Y-m-d", strtotime($this->due_date . " +3 months")); + break; + case 'semi_annually': + $repeatIssue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +6 months")) : null; + $repeatIssue->due_date = date("Y-m-d", strtotime($this->due_date . " +6 months")); + break; + case 'annually': + $repeatIssue->start_date = $this->start_date ? date("Y-m-d", strtotime($this->start_date . " +1 year")) : null; + $repeatIssue->due_date = date("Y-m-d", strtotime($this->due_date . " +1 year")); + break; + case 'sprint': + $sprint = new \Model\Sprint(); + $sprint->load(["start_date > NOW()"], ['order' => 'start_date']); + $repeatIssue->start_date = $this->start_date ? $sprint->start_date : null; + $repeatIssue->due_date = $sprint->end_date; + break; + default: + $repeatIssue->repeat_cycle = 'none'; + } + + // If the issue was in a sprint before, put it in a sprint again. + if ($this->sprint_id) { + $sprint = new \Model\Sprint(); + $sprint->load(["end_date >= ? AND start_date <= ?", $repeatIssue->due_date, $repeatIssue->due_date], ['order' => 'start_date']); + $repeatIssue->sprint_id = $sprint->id; + } + + $repeatIssue->save(); + if ($notify) { + $notification = \Helper\Notification::instance(); + $notification->issue_create($repeatIssue->id); + } + return $repeatIssue; + } + + /** + * Log and save an issue update + * @param bool $notify + * @return Issue\Update + */ + protected function _saveUpdate(bool $notify = true): Issue\Update + { + $f3 = \Base::instance(); + + // Ensure issue is not tied to itself as a parent + if ($this->id == $this->parent_id) { + $this->parent_id = $this->_getPrev("parent_id"); + } + + // Log update + $update = new \Model\Issue\Update(); + $update->issue_id = $this->id; + $update->user_id = $f3->get("user.id"); + $update->created_date = date("Y-m-d H:i:s"); + if ($f3->exists("update_comment")) { + $update->comment_id = $f3->get("update_comment")->id; + $update->notify = (int)$notify; + } else { + $update->notify = 0; + } + $update->save(); + + // Set hours_total to the hours_remaining value under certain conditions + if ( + $this->hours_remaining && !$this->hours_total && + !$this->_getPrev('hours_remaining') && + !$this->_getPrev('hours_total') + ) { + $this->hours_total = $this->hours_remaining; + } + + // Set hours remaining to 0 if the issue has been closed + if ($this->closed_date && $this->hours_remaining) { + $this->hours_remaining = 0; + } + + // Create a new issue if repeating + if ($this->closed_date && $this->repeat_cycle) { + $this->repeat($notify); + $this->repeat_cycle = null; + } + + // Log updated fields + $updated = 0; + $importantChanges = 0; + $importantFields = ['status', 'name', 'description', 'owner_id', 'priority', 'due_date']; + foreach ($this->fields as $key => $field) { + if ($field["changed"] && rtrim($field["value"] ?? '') != rtrim($this->_getPrev($key) ?? '')) { + $updateField = new \Model\Issue\Update\Field(); + $updateField->issue_update_id = $update->id; + $updateField->field = $key; + $updateField->old_value = $this->_getPrev($key); + $updateField->new_value = $field["value"]; + $updateField->save(); + $updated++; + if ($key == 'sprint_id') { + $this->resetTaskSprints(); + } + if (in_array($key, $importantFields)) { + $importantChanges++; + } + } + } + + // Delete update if no fields were changed + if (!$updated) { + $update->delete(); + } + + // Set notify flag if important changes occurred + if ($notify && $importantChanges) { + $update->notify = 1; + $update->save(); + } + + // Send back the update + return $update->id ? $update : false; + } + + /** + * Log issue update, send notifications + * @param bool $notify + * @return Issue + */ + public function save(bool $notify = true): Issue + { + $f3 = \Base::instance(); + + // Catch empty sprint at the lowest level here + if ($this->sprint_id === 0) { + $this->set("sprint_id", null); + } + + // Censor credit card numbers if enabled + if ($f3->get("security.block_ccs")) { + if (preg_match("/([0-9]{3,4}-){3}[0-9]{3,4}/", $this->description)) { + $this->set("description", preg_replace("/([0-9]{3,4}-){3}([0-9]{3,4})/", "************$2", $this->description)); + } + } + + // Make dates correct + if ($this->due_date) { + $this->due_date = date("Y-m-d", strtotime($this->due_date)); + } else { + $this->due_date = null; + } + if ($this->start_date) { + $this->start_date = date("Y-m-d", strtotime($this->start_date)); + } else { + $this->start_date = null; + } + + // Only save valid repeat_cycle values + if (!in_array($this->repeat_cycle, ['daily', 'weekly', 'monthly', 'quarterly', 'semi_annually', 'annually', 'sprint'])) { + $this->repeat_cycle = null; + } + + // Check if updating or inserting + if ($this->query) { + // Save issue updates and send notifications + $update = $this->_saveUpdate($notify); + $issue = parent::save(); + if ($notify && $update && $update->id && $update->notify) { + $notification = \Helper\Notification::instance(); + $notification->issue_update($this->id, $update->id); + } + } else { + // Set closed date if status is closed + if (!$this->closed_date && $this->status) { + $status = new Issue\Status(); + $status->load($this->status); + if ($status->closed) { + $this->closed_date = date("Y-m-d H:i:s"); + } + } + } + + $return = empty($issue) ? parent::save() : $issue; + $this->saveTags(); + return $return; + } + + /** + * Finds and saves the current issue's tags + * @return Issue + */ + public function saveTags(): Issue + { + $tag = new \Model\Issue\Tag(); + if ($this->id) { + $tag->deleteByIssueId($this->id); + } + if (!$this->deleted_date) { + $count = preg_match_all("/(?<=[^a-z\\/&]#|^#)[a-z][a-z0-9_-]*[a-z0-9]+(?=[^a-z\\/]|$)/i", $this->description, $matches); + if ($count) { + foreach ($matches[0] as $match) { + $tag->reset(); + $tag->tag = preg_replace("/[_-]+/", "-", ltrim($match, "#")); + $tag->issue_id = $this->id; + $tag->save(); + } + } + } + return $this; + } + + /** + * Duplicate issue and all sub-issues + * @param bool $recursive + * @return Issue New issue + * @throws \Exception + */ + public function duplicate(bool $recursive = true): Issue + { + if (!$this->id) { + throw new \Exception('Cannot duplicate an issue that is not yet saved.'); + } + + $f3 = \Base::instance(); + + $this->copyto("duplicating_issue"); + $f3->clear("duplicating_issue.id"); + $f3->clear("duplicating_issue.due_date"); + $f3->clear("duplicating_issue.hours_spent"); + + $newIssue = new Issue(); + $newIssue->copyfrom("duplicating_issue"); + $newIssue->author_id = $f3->get("user.id"); + $newIssue->hours_remaining = $newIssue->hours_total; + $newIssue->created_date = date("Y-m-d H:i:s"); + $newIssue->save(); + + if ($recursive) { + // Run the recursive function to duplicate the complete descendant tree + $this->_duplicateTree($this->id, $newIssue->id); + } + + return $newIssue; + } + + /** + * Duplicate a complete issue tree, starting from a duplicated issue created by duplicate() + * @param int $id + * @param int $newId + * @return Issue $this + */ + protected function _duplicateTree(int $id, int $newId): Issue + { + // Find all child issues + $children = $this->find(["parent_id = ?", $id]); + if (count($children)) { + $f3 = \Base::instance(); + foreach ($children as $child) { + if (!$child->deleted_date) { + // Duplicate issue + $child->copyto("duplicating_issue"); + $f3->clear("duplicating_issue.id"); + $f3->clear("duplicating_issue.due_date"); + $f3->clear("duplicating_issue.hours_spent"); + + $newChild = new Issue(); + $newChild->copyfrom("duplicating_issue"); + $newChild->author_id = $f3->get("user.id"); + $newChild->hours_remaining = $newChild->hours_total; + $newChild->parent_id = $newId; + $newChild->created_date = date("Y-m-d H:i:s"); + $newChild->save(false); + + // Duplicate issue's children + $this->_duplicateTree($child->id, $newChild->id); + } + } + } + return $this; + } + + /** + * Move all non-project children to same sprint + * @param bool $replaceExisting + * @return Issue $this + */ + public function resetTaskSprints(bool $replaceExisting = true): Issue + { + $f3 = \Base::instance(); + if ($this->sprint_id) { + $query = "UPDATE issue SET sprint_id = :sprint WHERE parent_id = :issue AND type_id != :type"; + if ($replaceExisting) { + $query .= " AND sprint_id IS NULL"; + } + $this->db->exec( + $query, + [ + ":sprint" => $this->sprint_id, + ":issue" => $this->id, + ":type" => $f3->get("issue_type.project"), + ] + ); + } + return $this; + } + + /** + * Get children of current issue + * @return array + */ + public function getChildren(): array + { + if ($this->_children !== null) { + return $this->_children; + } + + return $this->_children = $this->find(["parent_id = ? AND deleted_date IS NULL", $this->id]); + } + + /** + * Generate MD5 hashes for each column as a key=>value array + * @return array + */ + public function hashState(): array + { + $result = $this->cast(); + foreach ($result as &$value) { + if ($value === null) { + $value = md5(''); + } else { + $value = md5($value); + } + } + return $result; + } + + /** + * Close the issue + * @return Issue $this + */ + public function close(): Issue + { + if ($this->id && !$this->closed_date) { + $status = new \Model\Issue\Status(); + $status->load(["closed = ?", 1]); + $this->status = $status->id; + $this->closed_date = date("Y-m-d H:i:s"); + $this->save(); + } + return $this; + } + + /** + * Get array of all descendant IDs + * @return array + */ + public function descendantIds(): array + { + $ids = [$this->id]; + foreach ($this->getChildren() as $child) { + $ids[] = $child->id; + $ids = $ids + $child->descendantIds(); + } + return array_unique($ids); + } + + /** + * Get aggregate totals across the project and its descendants + * @return array + */ + public function projectStats(): array + { + $total = 0; + $complete = 0; + $hoursSpent = 0; + $hoursTotal = 0; + if ($this->id) { + $total++; + if ($this->closed_date) { + $complete++; + } + if ($this->hours_spent > 0) { + $hoursSpent += $this->hours_spent; + } + if ($this->hours_total > 0) { + $hoursTotal += $this->hours_total; + } + foreach ($this->getChildren() as $child) { + $result = $child->projectStats(); + $total += $result["total"]; + $complete += $result["complete"]; + $hoursSpent += $result["hours_spent"]; + $hoursTotal += $result["hours_total"]; + } + } + return [ + "total" => $total, + "complete" => $complete, + "hours_spent" => $hoursSpent, + "hours_total" => $hoursTotal, + ]; + } + + /** + * Check if the current/given should be allowed access to the issue. + * @return bool + */ + public function allowAccess(\Model\User $user = null): bool + { + $f3 = \Base::instance(); + if ($user === null) { + $user = $f3->get("user_obj"); + } + + if ($user->role == 'admin') { + return true; + } + + if ($this->deleted_date) { + return false; + } + + if (!$f3->get('security.restrict_access')) { + return true; + } + + $helper = \Helper\Dashboard::instance(); + return ($this->owner_id == $user->id) + || ($this->author_id == $user->id) + || in_array($this->owner_id, $helper->getGroupIds()); + } } diff --git a/app/model/issue/backlog.php b/app/model/issue/backlog.php index 8b1c5b46..7fc4087c 100644 --- a/app/model/issue/backlog.php +++ b/app/model/issue/backlog.php @@ -9,9 +9,7 @@ * @property int $user_id * @property string $issues */ -class Backlog extends \Model { - - protected $_table_name = "issue_backlog"; - +class Backlog extends \Model +{ + protected $_table_name = "issue_backlog"; } - diff --git a/app/model/issue/comment.php b/app/model/issue/comment.php index e0f91317..87c61a03 100644 --- a/app/model/issue/comment.php +++ b/app/model/issue/comment.php @@ -12,44 +12,43 @@ * @property int $file_id * @property string $created_date */ -class Comment extends \Model { - - protected $_table_name = "issue_comment"; - protected static $requiredFields = array("issue_id", "user_id", "text"); - - /** - * Create and save a new comment - * @param array $data - * @param bool $notify - * @return Comment - * @throws \Exception - */ - public static function create(array $data, $notify = true) { - if(empty($data['text'])) { - throw new \Exception("Comment text cannot be empty."); - } - /** @var Comment $item */ - $item = parent::create($data); - if($notify) { - $notification = \Helper\Notification::instance(); - $notification->issue_comment($item->issue_id, $item->id); - } - return $item; - } - - /** - * Save the comment - * @return Comment - */ - public function save() { - - // Censor credit card numbers if enabled - if(\Base::instance()->get("security.block_ccs") && preg_match("/[0-9-]{9,15}[0-9]{4}/", $this->get("text"))) { - $this->set("text", preg_replace("/[0-9-]{9,15}([0-9]{4})/", "************$1", $this->get("text"))); - } - - return parent::save(); - } - +class Comment extends \Model +{ + protected $_table_name = "issue_comment"; + protected static $requiredFields = ["issue_id", "user_id", "text"]; + + /** + * Create and save a new comment + * @param array $data + * @param bool $notify + * @return Comment + * @throws \Exception + */ + public static function create(array $data, bool $notify = true): Comment + { + if (empty($data['text'])) { + throw new \Exception("Comment text cannot be empty."); + } + /** @var Comment $item */ + $item = parent::create($data); + if ($notify) { + $notification = \Helper\Notification::instance(); + $notification->issue_comment($item->issue_id, $item->id); + } + return $item; + } + + /** + * Save the comment + * @return Comment + */ + public function save(): Comment + { + // Censor credit card numbers if enabled + if (\Base::instance()->get("security.block_ccs") && preg_match("/[0-9-]{9,15}[0-9]{4}/", $this->get("text"))) { + $this->set("text", preg_replace("/[0-9-]{9,15}([0-9]{4})/", "************$1", $this->get("text"))); + } + + return parent::save(); + } } - diff --git a/app/model/issue/comment/detail.php b/app/model/issue/comment/detail.php index 0fe80d03..12f44391 100644 --- a/app/model/issue/comment/detail.php +++ b/app/model/issue/comment/detail.php @@ -2,9 +2,7 @@ namespace Model\Issue\Comment; -class Detail extends \Model\Issue\Comment { - - protected $_table_name = "issue_comment_detail"; - +class Detail extends \Model\Issue\Comment +{ + protected $_table_name = "issue_comment_detail"; } - diff --git a/app/model/issue/comment/user.php b/app/model/issue/comment/user.php index a63aa325..e4b3f46a 100644 --- a/app/model/issue/comment/user.php +++ b/app/model/issue/comment/user.php @@ -2,9 +2,7 @@ namespace Model\Issue\Comment; -class User extends \Model\Issue\Comment { - - protected $_table_name = "issue_comment_user"; - +class User extends \Model\Issue\Comment +{ + protected $_table_name = "issue_comment_user"; } - diff --git a/app/model/issue/dependency.php b/app/model/issue/dependency.php index 80c6d231..3b53f09e 100644 --- a/app/model/issue/dependency.php +++ b/app/model/issue/dependency.php @@ -10,40 +10,41 @@ * @property int $dependency_id * @property string $dependency_type */ -class Dependency extends \Model { +class Dependency extends \Model +{ + protected $_table_name = "issue_dependency"; - protected $_table_name = "issue_dependency"; - - /** - * Find dependency issues by issue_id - * @param int $issue_id - * @param string $orderby - * @return array - */ - public function findby_issue ($issue_id, $orderby = 'due_date') { - return $this->db->exec( - 'SELECT d.id as d_id,i.id, i.name, i.start_date, i.due_date, i.status_closed, i.author_name, i.author_username, i.owner_name, i.owner_username, i.status_name, i.status, d.dependency_type '. - 'FROM issue_detail i JOIN issue_dependency d on i.id = d.dependency_id '. - 'WHERE d.issue_id = :issue_id AND i.deleted_date IS NULL '. - 'ORDER BY :orderby ', - array(':issue_id' => $issue_id, ':orderby' => $orderby) - ); - } - - /** - * Find dependent issues by issue_id - * @param int $issue_id - * @param string $orderby - * @return array - */ - public function findby_dependent ($issue_id, $orderby = 'due_date') { - return $this->db->exec( - 'SELECT d.id as d_id, i.id, i.name, i.start_date, i.due_date, i.status_closed, i.author_name, i.author_username, i.owner_name, i.owner_username, i.status_name, i.status, d.dependency_type '. - 'FROM issue_detail i JOIN issue_dependency d on i.id = d.issue_id '. - 'WHERE d.dependency_id = :issue_id AND i.deleted_date IS NULL '. - 'ORDER BY :orderby ', - array(':issue_id' => $issue_id, ':orderby' => $orderby) - ); - } + /** + * Find dependency issues by issue ID + * @param int $issueId + * @param string $orderby + * @return array + */ + public function findby_issue($issueId, $orderby = 'due_date'): array + { + return $this->db->exec( + 'SELECT d.id as d_id,i.id, i.name, i.start_date, i.due_date, i.status_closed, i.author_name, i.author_username, i.owner_name, i.owner_username, i.status_name, i.status, d.dependency_type ' . + 'FROM issue_detail i JOIN issue_dependency d on i.id = d.dependency_id ' . + 'WHERE d.issue_id = :issue_id AND i.deleted_date IS NULL ' . + 'ORDER BY :orderby', + [':issue_id' => $issueId, ':orderby' => $orderby] + ); + } + /** + * Find dependent issues by issue ID + * @param int $issueId + * @param string $orderby + * @return array + */ + public function findby_dependent($issueId, $orderby = 'due_date'): array + { + return $this->db->exec( + 'SELECT d.id as d_id, i.id, i.name, i.start_date, i.due_date, i.status_closed, i.author_name, i.author_username, i.owner_name, i.owner_username, i.status_name, i.status, d.dependency_type ' . + 'FROM issue_detail i JOIN issue_dependency d on i.id = d.issue_id ' . + 'WHERE d.dependency_id = :issue_id AND i.deleted_date IS NULL ' . + 'ORDER BY :orderby', + [':issue_id' => $issueId, ':orderby' => $orderby] + ); + } } diff --git a/app/model/issue/detail.php b/app/model/issue/detail.php index cf8c2073..82aa03f7 100644 --- a/app/model/issue/detail.php +++ b/app/model/issue/detail.php @@ -2,9 +2,8 @@ namespace Model\Issue; -class Detail extends \Model\Issue { - - protected $_table_name = "issue_detail"; - +class Detail extends \Model\Issue +{ + protected $_table_name = "issue_detail"; + public $children = []; } - diff --git a/app/model/issue/file.php b/app/model/issue/file.php index cccf400f..ab5f262f 100644 --- a/app/model/issue/file.php +++ b/app/model/issue/file.php @@ -18,25 +18,25 @@ * @property string $created_date * @property string $deleted_date */ -class File extends \Model { - - protected $_table_name = "issue_file"; - protected static $requiredFields = array("issue_id", "user_id", "filename", "disk_filename"); - - /** - * Create and save a new file, optionally sending notifications - * @param array $data - * @param bool $notify - * @return File - */ - public static function create(array $data, $notify = true) { - /** @var File $item */ - $item = parent::create($data); - if($notify) { - $notification = \Helper\Notification::instance(); - $notification->issue_file($item->issue_id, $item->id); - } - return $item; - } +class File extends \Model +{ + protected $_table_name = "issue_file"; + protected static $requiredFields = ["issue_id", "user_id", "filename", "disk_filename"]; + /** + * Create and save a new file, optionally sending notifications + * @param array $data + * @param bool $notify + * @return File + */ + public static function create(array $data, bool $notify = true): File + { + /** @var File $item */ + $item = parent::create($data); + if ($notify) { + $notification = \Helper\Notification::instance(); + $notification->issue_file($item->issue_id, $item->id); + } + return $item; + } } diff --git a/app/model/issue/file/detail.php b/app/model/issue/file/detail.php index d7d90fdd..ccfc1c73 100644 --- a/app/model/issue/file/detail.php +++ b/app/model/issue/file/detail.php @@ -2,9 +2,7 @@ namespace Model\Issue\File; -class Detail extends \Model\Issue\File { - - protected $_table_name = "issue_file_detail"; - +class Detail extends \Model\Issue\File +{ + protected $_table_name = "issue_file_detail"; } - diff --git a/app/model/issue/priority.php b/app/model/issue/priority.php index 7068cd4b..3ad2e07e 100644 --- a/app/model/issue/priority.php +++ b/app/model/issue/priority.php @@ -9,9 +9,7 @@ * @property int $value * @property string $name */ -class Priority extends \Model { - - protected $_table_name = "issue_priority"; - +class Priority extends \Model +{ + protected $_table_name = "issue_priority"; } - diff --git a/app/model/issue/status.php b/app/model/issue/status.php index 568bb18c..611b2460 100644 --- a/app/model/issue/status.php +++ b/app/model/issue/status.php @@ -10,9 +10,7 @@ * @property int $closed * @property int $taskboard */ -class Status extends \Model { - - protected $_table_name = "issue_status"; - +class Status extends \Model +{ + protected $_table_name = "issue_status"; } - diff --git a/app/model/issue/tag.php b/app/model/issue/tag.php index 2908a888..2e9ff75b 100644 --- a/app/model/issue/tag.php +++ b/app/model/issue/tag.php @@ -9,44 +9,45 @@ * @property string $tag * @property int $issue_id */ -class Tag extends \Model { - - protected $_table_name = "issue_tag"; - - /** - * Delete all stored tags for an issue - * @param int $issue_id - * @return Tag - */ - public function deleteByIssueId($issue_id) { - $this->db->exec("DELETE FROM {$this->_table_name} WHERE issue_id = ?", $issue_id); - return $this; - } - - /** - * Get a multidimensional array representing a tag cloud - * @return array - */ - public function cloud() { - return $this->db->exec("SELECT tag, COUNT(*) AS freq FROM {$this->_table_name} GROUP BY tag ORDER BY freq DESC"); - } - - /** - * Find issues with the given/current tag - * @param string $tag - * @return array Issue IDs - */ - public function issues($tag = '') { - if(!$tag) { - $tag = $this->get("tag"); - } - $result = $this->db->exec("SELECT DISTINCT issue_id FROM {$this->_table_name} WHERE tag = ?", $tag); - $return = array(); - foreach($result as $r) { - $return[] = $r["issue_id"]; - } - return $return; - } - +class Tag extends \Model +{ + protected $_table_name = "issue_tag"; + + /** + * Delete all stored tags for an issue + * @param int $issueId + * @return Tag + */ + public function deleteByIssueId(int $issueId): Tag + { + $this->db->exec("DELETE FROM {$this->_table_name} WHERE issue_id = ?", $issueId); + return $this; + } + + /** + * Get a multidimensional array representing a tag cloud + * @return array + */ + public function cloud(): array + { + return $this->db->exec("SELECT tag, COUNT(*) AS freq FROM {$this->_table_name} GROUP BY tag ORDER BY freq DESC"); + } + + /** + * Find issues with the given/current tag + * @param string $tag + * @return array Issue IDs + */ + public function issues($tag = ''): array + { + if (!$tag) { + $tag = $this->get("tag"); + } + $result = $this->db->exec("SELECT DISTINCT issue_id FROM {$this->_table_name} WHERE tag = ?", $tag); + $return = []; + foreach ($result as $r) { + $return[] = $r["issue_id"]; + } + return $return; + } } - diff --git a/app/model/issue/type.php b/app/model/issue/type.php index b88f43cc..3eb15f52 100644 --- a/app/model/issue/type.php +++ b/app/model/issue/type.php @@ -8,9 +8,7 @@ * @property int $id * @property string $name */ -class Type extends \Model { - - protected $_table_name = "issue_type"; - +class Type extends \Model +{ + protected $_table_name = "issue_type"; } - diff --git a/app/model/issue/update.php b/app/model/issue/update.php index 84b3f35f..19ff0341 100644 --- a/app/model/issue/update.php +++ b/app/model/issue/update.php @@ -12,9 +12,7 @@ * @property int $comment_id * @property int $notify */ -class Update extends \Model { - - protected $_table_name = "issue_update"; - +class Update extends \Model +{ + protected $_table_name = "issue_update"; } - diff --git a/app/model/issue/update/field.php b/app/model/issue/update/field.php index 4afbb86f..c670e83d 100644 --- a/app/model/issue/update/field.php +++ b/app/model/issue/update/field.php @@ -11,9 +11,7 @@ * @property string $old_value * @property string $new_value */ -class Field extends \Model { - - protected $_table_name = "issue_update_field"; - +class Field extends \Model +{ + protected $_table_name = "issue_update_field"; } - diff --git a/app/model/issue/watcher.php b/app/model/issue/watcher.php index 65d11de4..151436ee 100644 --- a/app/model/issue/watcher.php +++ b/app/model/issue/watcher.php @@ -9,23 +9,23 @@ * @property int $issue_id * @property int $user_id */ -class Watcher extends \Model { - - protected $_table_name = "issue_watcher"; - - /** - * Find watched issues by user ID - * @param int $user_id - * @param string $orderby - * @return array - */ - public function findby_watcher ($user_id, $orderby = 'id') { - return $this->db->exec( - 'SELECT i.* FROM issue_detail i JOIN issue_watcher w on i.id = w.issue_id '. - 'WHERE w.user_id = :user_id AND i.deleted_date IS NULL AND i.closed_date IS NULL AND i.status_closed = 0 AND i.owner_id != :user_id2 '. - 'ORDER BY :orderby ', - array(':user_id' => $user_id, ':user_id2' => $user_id, ':orderby' => $orderby) - ); - } +class Watcher extends \Model +{ + protected $_table_name = "issue_watcher"; + /** + * Find watched issues by user ID + * @param int $userId + * @param string $orderby + * @return array + */ + public function findby_watcher(int $userId, string $orderby = 'id') + { + return $this->db->exec( + 'SELECT i.* FROM issue_detail i JOIN issue_watcher w on i.id = w.issue_id ' . + 'WHERE w.user_id = :user_id AND i.deleted_date IS NULL AND i.closed_date IS NULL AND i.status_closed = 0 AND i.owner_id != :user_id2 ' . + 'ORDER BY :orderby', + [':user_id' => $userId, ':user_id2' => $userId, ':orderby' => $orderby] + ); + } } diff --git a/app/model/session.php b/app/model/session.php index b9794279..ddcf2225 100644 --- a/app/model/session.php +++ b/app/model/session.php @@ -11,108 +11,108 @@ * @property int $user_id * @property string $created */ -class Session extends \Model { - - protected $_table_name = "session"; - const COOKIE_NAME = "phproj_token"; - - /** - * Create a new session - * @param int $user_id - * @param bool $auto_save - */ - public function __construct($user_id = null, $auto_save = true) { - - // Run model constructor - parent::__construct(); - - if($user_id !== null) { - $this->user_id = $user_id; - $this->token = \Helper\Security::instance()->salt_sha2(); - $this->ip = \Base::instance()->get("IP"); - $this->created = date("Y-m-d H:i:s"); - if($auto_save) { - $this->save(); - } - } - - } - - /** - * Load the current session - * @return Session - */ - public function loadCurrent() { - $f3 = \Base::instance(); - $ip = $f3->get("IP"); - $token = $f3->get("COOKIE.".self::COOKIE_NAME); - if($token) { - $this->load(array("token = ? AND ip = ?", $token, $ip)); - $expire = $f3->get("JAR.expire"); - - // Delete expired sessions - if(time() - $expire > strtotime($this->created)) { - $this->delete(); - return $this; - } - - // Update nearly expired sessions - if(time() - $expire / 2 > strtotime($this->created)) { - if($f3->get("DEBUG")) { - $log = new \Log("session.log"); - $log->write("Updating expiration: " . json_encode($this->cast()) - . "; new date: " . date("Y-m-d H:i:s")); - } - $this->created = date("Y-m-d H:i:s"); - $this->save(); - $this->setCurrent(); - } - } - return $this; - } - - /** - * Set the user's cookie to the current session - * @return Session - */ - public function setCurrent() { - $f3 = \Base::instance(); - - if($f3->get("DEBUG")) { - $log = new \Log("session.log"); - $log->write("Setting current session: " . json_encode($this->cast())); - } - - $f3->set("COOKIE.".self::COOKIE_NAME, $this->token, $f3->get("JAR.expire")); - return $this; - } - - /** - * Delete the session - * @return Session - */ - public function delete() { - if(!$this->id) { - return $this; - } - - $f3 = \Base::instance(); - - if($f3->get("DEBUG")) { - $log = new \Log("session.log"); - $log->write("Deleting session: " . json_encode($this->cast())); - } - - // Empty the session cookie if it matches the current token - if($this->token == $f3->get("COOKIE.".self::COOKIE_NAME)) { - $f3->set("COOKIE.".self::COOKIE_NAME, ""); - } - - // Delete the session row - parent::delete(); - - return $this; - } - +class Session extends \Model +{ + protected $_table_name = "session"; + public const COOKIE_NAME = "phproj_token"; + + /** + * Create a new session + * @param int $user_id + * @param bool $auto_save + */ + public function __construct($user_id = null, bool $auto_save = true) + { + // Run model constructor + parent::__construct(); + + if ($user_id !== null) { + $this->user_id = $user_id; + $this->token = \Helper\Security::instance()->salt_sha2(); + $this->ip = \Base::instance()->get("IP"); + $this->created = date("Y-m-d H:i:s"); + if ($auto_save) { + $this->save(); + } + } + } + + /** + * Load the current session + * @return Session + */ + public function loadCurrent(): Session + { + $f3 = \Base::instance(); + $token = $f3->get("COOKIE." . self::COOKIE_NAME); + if ($token && $this->load(["token = ?", $token])) { + $lifetime = $f3->get("session_lifetime"); + $duration = time() - strtotime($this->created); + + // Delete expired sessions + if ($duration > $lifetime) { + $this->delete(); + return $this; + } + + // Update nearly expired sessions + if ($duration > $lifetime / 2) { + if ($f3->get("DEBUG")) { + $log = new \Log("session.log"); + $log->write("Updating expiration: " . json_encode($this->cast(), JSON_THROW_ON_ERROR) + . "; new date: " . date("Y-m-d H:i:s")); + } + $this->created = date("Y-m-d H:i:s"); + $this->ip = $f3->get("IP"); + $this->save(); + $this->setCurrent(); + } + } + return $this; + } + + /** + * Set the user's cookie to the current session + * @return Session + */ + public function setCurrent(): Session + { + $f3 = \Base::instance(); + + if ($f3->get("DEBUG")) { + $log = new \Log("session.log"); + $log->write("Setting current session: " . json_encode($this->cast(), JSON_THROW_ON_ERROR)); + } + + $f3->set("COOKIE." . self::COOKIE_NAME, $this->token, $f3->get("session_lifetime")); + return $this; + } + + /** + * Delete the session + * @return Session + */ + public function delete(): Session + { + if (!$this->id) { + return $this; + } + + $f3 = \Base::instance(); + + if ($f3->get("DEBUG")) { + $log = new \Log("session.log"); + $log->write("Deleting session: " . json_encode($this->cast(), JSON_THROW_ON_ERROR)); + } + + // Empty the session cookie if it matches the current token + if ($this->token == $f3->get("COOKIE." . self::COOKIE_NAME)) { + $f3->set("COOKIE." . self::COOKIE_NAME, ""); + } + + // Delete the session row + parent::delete(); + + return $this; + } } - diff --git a/app/model/sprint.php b/app/model/sprint.php index 358c2223..e88b3b11 100644 --- a/app/model/sprint.php +++ b/app/model/sprint.php @@ -10,9 +10,29 @@ * @property string $start_date * @property string $end_date */ -class Sprint extends \Model { +class Sprint extends \Model +{ + protected $_table_name = "sprint"; - protected $_table_name = "sprint"; + public function getFirstWeekday() + { + $weekDay = date("w", strtotime($this->start_date)); + if ($weekDay == 0) { + return date("Y-m-d", strtotime($this->start_date . " +1 day")); + } elseif ($weekDay == 6) { + return date("Y-m-d", strtotime($this->start_date . " +2 days")); + } + return $this->start_date; + } + public function getLastWeekday() + { + $weekDay = date("w", strtotime($this->end_date)); + if ($weekDay == 0) { + return date("Y-m-d", strtotime($this->end_date . " -2 days")); + } elseif ($weekDay == 6) { + return date("Y-m-d", strtotime($this->end_date . " -1 day")); + } + return $this->end_date; + } } - diff --git a/app/model/user.php b/app/model/user.php index e0d9ffbf..bcc8cd54 100644 --- a/app/model/user.php +++ b/app/model/user.php @@ -22,265 +22,345 @@ * @property string $created_date * @property string $deleted_date */ -class User extends \Model { - - const - RANK_GUEST = 0, - RANK_CLIENT = 1, - RANK_USER = 2, - RANK_MANAGER = 3, - RANK_ADMIN = 4, - RANK_SUPER = 5; - - protected - $_table_name = "user", - $_groupUsers = null; - - /** - * Load currently logged in user, if any - * @return mixed - */ - public function loadCurrent() { - $f3 = \Base::instance(); - - // Load current session - $session = new Session; - $session->loadCurrent(); - - // Load user - if($session->user_id) { - $this->load(array("id = ? AND deleted_date IS NULL", $session->user_id)); - if($this->id) { - $f3->set("user", $this->cast()); - $f3->set("user_obj", $this); - - // Change default language if user has selected one - if($this->exists("language") && $this->language) { - $f3->set("LANGUAGE", $this->language); - } - - } - } - - return $this; - } - - /** - * Get path to user's avatar or gravatar - * @param integer $size - * @return string|bool - */ - public function avatar($size = 80) { - if(!$this->id) { - return false; - } - if($this->get("avatar_filename") && is_file("uploads/avatars/" . $this->get("avatar_filename"))) { - return "/avatar/$size-" . $this->id . ".png"; - } - return \Helper\View::instance()->gravatar($this->get("email"), $size); - } - - /** - * Load all active users - * @return array - */ - public function getAll() { - return $this->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC")); - } - - /** - * Load all deleted users - * @return array - */ - public function getAllDeleted() { - return $this->find("deleted_date IS NOT NULL AND role != 'group'", array("order" => "name ASC")); - } - - /** - * Load all active groups - * @return array - */ - public function getAllGroups() { - return $this->find("deleted_date IS NULL AND role = 'group'", array("order" => "name ASC")); - } - - /** - * Get all users within a group - * @return array|NULL - */ - public function getGroupUsers() { - if($this->role == "group") { - if($this->_groupUsers !== null) { - return $this->_groupUsers; - } - $ug = new User\Group; - /** @var User\Group[] $users */ - $users = $ug->find(array("group_id = ?", $this->id)); - $user_ids = array(); - foreach($users as $user) { - $user_ids[] = $user->user_id; - } - return $this->_groupUsers = $user_ids ? $this->find("id IN (" . implode(",", $user_ids) . ") AND deleted_date IS NULL") : array(); - } else { - return null; - } - } - - /** - * Get all user options - * @return array - */ - public function options() { - return $this->options ? json_decode($this->options, true) : array(); - } - - /** - * Get or set a user option - * @param string $key - * @param mixed $value - * @return mixed - */ - public function option($key, $value = null) { - $options = $this->options(); - if($value === null) { - return isset($options[$key]) ? $options[$key] : null; - } - $options[$key] = $value; - $this->options = json_encode($options); - return $this; - } - - /** - * Send an email alert with issues due on the given date - * @param string $date - * @return bool - */ - public function sendDueAlert($date = '') { - if(!$this->id) { - return false; - } - - if(!$date) { - $date = date("Y-m-d", \Helper\View::instance()->utc2local()); - } - - // Get group owner IDs - $ownerIds = array($this->id); - $groups = new \Model\User\Group(); - foreach($groups->find(array("user_id = ?", $this->id)) as $r) { - $ownerIds[] = $r->group_id; - } - $ownerStr = implode(",", $ownerIds); - - // Find issues assigned to user or user's group - $issue = new Issue; - $issues = $issue->find(array("due_date = ? AND owner_id IN($ownerStr) AND closed_date IS NULL AND deleted_date IS NULL", $date), array("order" => "priority DESC")); - - if($issues) { - $notif = new \Helper\Notification; - return $notif->user_due_issues($this, $issues); - } else { - return false; - } - } - - /** - * Get user statistics - * @param int $time The lower limit on timestamps for stats collection - * @return array - */ - public function stats($time = 0) { - \Helper\View::instance()->utc2local(); - $offset = \Base::instance()->get("site.timeoffset"); - - if(!$time) { - $time = strtotime("-2 weeks", time() + $offset); - } - - $result = array(); - $result["spent"] = $this->db->exec( - "SELECT DATE(DATE_ADD(u.created_date, INTERVAL :offset SECOND)) AS `date`, SUM(f.new_value - f.old_value) AS `val` - FROM issue_update u - JOIN issue_update_field f ON u.id = f.issue_update_id AND f.field = 'hours_spent' - WHERE u.user_id = :user AND u.created_date > :date - GROUP BY DATE(DATE_ADD(u.created_date, INTERVAL :offset2 SECOND))", - array(":user" => $this->id, ":offset" => $offset, ":offset2" => $offset, ":date" => date("Y-m-d H:i:s", $time)) - ); - $result["closed"] = $this->db->exec( - "SELECT DATE(DATE_ADD(i.closed_date, INTERVAL :offset SECOND)) AS `date`, COUNT(*) AS `val` - FROM issue i - WHERE i.owner_id = :user AND i.closed_date > :date - GROUP BY DATE(DATE_ADD(i.closed_date, INTERVAL :offset2 SECOND))", - array(":user" => $this->id, ":offset" => $offset, ":offset2" => $offset, ":date" => date("Y-m-d H:i:s", $time)) - ); - $result["created"] = $this->db->exec( - "SELECT DATE(DATE_ADD(i.created_date, INTERVAL :offset SECOND)) AS `date`, COUNT(*) AS `val` - FROM issue i - WHERE i.author_id = :user AND i.created_date > :date - GROUP BY DATE(DATE_ADD(i.created_date, INTERVAL :offset2 SECOND))", - array(":user" => $this->id, ":offset" => $offset, ":offset2" => $offset, ":date" => date("Y-m-d H:i:s", $time)) - ); - - $dates = $this->_createDateRangeArray(date("Y-m-d", $time), date("Y-m-d", time() + $offset)); - $return = array( - "labels" => array(), - "spent" => array(), - "closed" => array(), - "created" => array() - ); - - foreach($result["spent"] as $r) { - $return["spent"][$r["date"]] = floatval($r["val"]); - } - foreach($result["closed"] as $r) { - $return["closed"][$r["date"]] = intval($r["val"]); - } - foreach($result["created"] as $r) { - $return["created"][$r["date"]] = intval($r["val"]); - } - - foreach($dates as $date) { - $return["labels"][$date] = date("D j", strtotime($date)); - if(!isset($return["spent"][$date])) { - $return["spent"][$date] = 0; - } - if(!isset($return["closed"][$date])) { - $return["closed"][$date] = 0; - } - if(!isset($return["created"][$date])) { - $return["created"][$date] = 0; - } - } - - foreach($return as &$r) { - ksort($r); - } - - return $return; - } - - /** - * Reassign assigned issues - * @param int $user_id - * @return int Number of issues affected - * @throws \Exception - */ - public function reassignIssues($user_id) { - if(!$this->id) { - throw new \Exception("User is not initialized."); - } - $issue_model = new Issue; - $issues = $issue_model->find(array("owner_id = ? AND deleted_date IS NULL AND closed_date IS NULL", $this->id)); - foreach($issues as $issue) { - $issue->owner_id = $user_id; - $issue->save(); - } - return count($issues); - } - - public function date_picker() { - $lang = $this->language ?: \Base::instance()->get("LANGUAGE"); - return (object) array("language" => $lang, "js" => ($lang != "en")); - } - +class User extends \Model +{ + public const + RANK_GUEST = 0, + RANK_CLIENT = 1, + RANK_USER = 2, + RANK_MANAGER = 3, + RANK_ADMIN = 4, + RANK_SUPER = 5; + + protected $_table_name = "user"; + protected $_groupUsers = null; + + /** + * Load currently logged in user, if any + * @return mixed + */ + public function loadCurrent() + { + $f3 = \Base::instance(); + + // Load current session + $session = new Session(); + $session->loadCurrent(); + + // Load user + if ($session->user_id) { + $this->load(["id = ? AND deleted_date IS NULL", $session->user_id]); + if ($this->id) { + $f3->set("user", $this->cast()); + $f3->set("user_obj", $this); + + // Change default language if user has selected one + if ($this->exists("language") && $this->language) { + $f3->set("LANGUAGE", $this->language); + } + } + } + + return $this; + } + + /** + * Get path to user's avatar or gravatar + * @param int $size + * @return string|bool + */ + public function avatar(int $size = 80) + { + if (!$this->id) { + return false; + } + if ($this->get("avatar_filename") && is_file("uploads/avatars/" . $this->get("avatar_filename"))) { + return \Base::instance()->get('BASE') . "/avatar/$size-{$this->id}.png"; + } + return \Helper\View::instance()->gravatar($this->get("email"), $size); + } + + /** + * Load all active users + * @return array + */ + public function getAll(): array + { + return $this->find("deleted_date IS NULL AND role != 'group'", ["order" => "name ASC"]); + } + + /** + * Load all deleted users + * @return array + */ + public function getAllDeleted(): array + { + return $this->find("deleted_date IS NOT NULL AND role != 'group'", ["order" => "name ASC"]); + } + + /** + * Load all active groups + * @return array + */ + public function getAllGroups(): array + { + return $this->find("deleted_date IS NULL AND role = 'group'", ["order" => "name ASC"]); + } + + /** + * Get all users within a group + * @return array|NULL + */ + public function getGroupUsers() + { + if ($this->role == "group") { + if ($this->_groupUsers !== null) { + return $this->_groupUsers; + } + $ug = new User\Group(); + /** @var User\Group[] $users */ + $users = $ug->find(["group_id = ?", $this->id]); + $userIds = []; + foreach ($users as $user) { + $userIds[] = $user->user_id; + } + return $this->_groupUsers = $userIds ? $this->find("id IN (" . implode(",", $userIds) . ") AND deleted_date IS NULL") : []; + } else { + return null; + } + } + + /** + * Get array of IDs of users within a group + * @return array|NULL + */ + public function getGroupUserIds() + { + if ($this->role == "group") { + if ($this->_groupUsers === null) { + $this->getGroupUsers(); + } + $ids = []; + foreach ($this->_groupUsers as $u) { + $ids[] = $u->id; + } + return $ids; + } else { + return null; + } + } + + /** + * Get all user IDs in a group with a user, and all group IDs the user is in + * @return array + */ + public function getSharedGroupUserIds(): array + { + $groupModel = new \Model\User\Group(); + $groups = $groupModel->find(["user_id = ?", $this->id]); + $groupIds = []; + foreach ($groups as $g) { + $groupIds[] = $g["group_id"]; + } + $ids = $groupIds; + if ($groupIds) { + $groupIdString = implode(",", $groupIds); + $users = $groupModel->find("group_id IN ({$groupIdString})", ["group" => "id,user_id"]); + foreach ($users as $u) { + $ids[] = $u->user_id; + } + } + if (!count($ids)) { + return [$this->id]; + } + return $ids; + } + /** + * Get all user options + * @return array + */ + public function options(): array + { + return $this->options ? json_decode($this->options, true, 512, JSON_THROW_ON_ERROR) : []; + } + + /** + * Get or set a user option + * @param string $key + * @param mixed $value + * @return mixed + */ + public function option(string $key, $value = null) + { + $options = $this->options(); + if ($value === null) { + return $options[$key] ?? null; + } + $options[$key] = $value; + $this->options = json_encode($options, JSON_THROW_ON_ERROR); + return $this; + } + + /** + * Send an email alert with issues due on the given date + * @param string $date + * @return bool + */ + public function sendDueAlert(string $date = ''): bool + { + if (!$this->id) { + return false; + } + + if (!$date) { + $date = date("Y-m-d", \Helper\View::instance()->utc2local()); + } + + // Get group owner IDs + $ownerIds = [$this->id]; + $groups = new \Model\User\Group(); + foreach ($groups->find(["user_id = ?", $this->id]) as $r) { + $ownerIds[] = $r->group_id; + } + $ownerStr = implode(",", $ownerIds); + + // Find issues assigned to user or user's group + $issue = new Issue(); + $due = $issue->find(["due_date = ? AND owner_id IN($ownerStr) AND closed_date IS NULL AND deleted_date IS NULL", $date], ["order" => "priority DESC"]); + $overdue = $issue->find(["due_date < ? AND owner_id IN($ownerStr) AND closed_date IS NULL AND deleted_date IS NULL", $date], ["order" => "priority DESC"]); + + if ($due || $overdue) { + $notif = new \Helper\Notification(); + return $notif->user_due_issues($this, $due, $overdue); + } else { + return false; + } + } + + /** + * Get user statistics + * @param int $time The lower limit on timestamps for stats collection + * @return array + */ + public function stats(int $time = 0): array + { + $offset = \Helper\View::instance()->timeoffset(); + + if (!$time) { + $time = strtotime("-2 weeks", time() + $offset); + } + + $result = []; + $result["spent"] = $this->db->exec( + "SELECT DATE(DATE_ADD(u.created_date, INTERVAL :offset SECOND)) AS `date`, SUM(f.new_value - f.old_value) AS `val` + FROM issue_update u + JOIN issue_update_field f ON u.id = f.issue_update_id AND f.field = 'hours_spent' + WHERE u.user_id = :user AND u.created_date > :date + GROUP BY `date`", + [":user" => $this->id, ":offset" => $offset, ":date" => date("Y-m-d H:i:s", $time)] + ); + $result["closed"] = $this->db->exec( + "SELECT DATE(DATE_ADD(i.closed_date, INTERVAL :offset SECOND)) AS `date`, COUNT(*) AS `val` + FROM issue i + WHERE i.owner_id = :user AND i.closed_date > :date + GROUP BY `date`", + [":user" => $this->id, ":offset" => $offset, ":date" => date("Y-m-d H:i:s", $time)] + ); + $result["created"] = $this->db->exec( + "SELECT DATE(DATE_ADD(i.created_date, INTERVAL :offset SECOND)) AS `date`, COUNT(*) AS `val` + FROM issue i + WHERE i.author_id = :user AND i.created_date > :date + GROUP BY `date`", + [":user" => $this->id, ":offset" => $offset, ":date" => date("Y-m-d H:i:s", $time)] + ); + + $dates = $this->_createDateRangeArray(date("Y-m-d", $time), date("Y-m-d", time() + $offset)); + $return = [ + "labels" => [], + "spent" => [], + "closed" => [], + "created" => [], + ]; + + foreach ($result["spent"] as $r) { + $return["spent"][$r["date"]] = floatval($r["val"]); + } + foreach ($result["closed"] as $r) { + $return["closed"][$r["date"]] = intval($r["val"]); + } + foreach ($result["created"] as $r) { + $return["created"][$r["date"]] = intval($r["val"]); + } + + foreach ($dates as $date) { + $return["labels"][$date] = date("D j", strtotime($date)); + if (!isset($return["spent"][$date])) { + $return["spent"][$date] = 0; + } + if (!isset($return["closed"][$date])) { + $return["closed"][$date] = 0; + } + if (!isset($return["created"][$date])) { + $return["created"][$date] = 0; + } + } + + foreach ($return as &$r) { + ksort($r); + } + + return $return; + } + + /** + * Reassign open assigned issues + * @param int|null $userId + * @return int Number of issues affected + * @throws \Exception + */ + public function reassignIssues(?int $userId): int + { + if (!$this->id) { + throw new \Exception("User is not initialized."); + } + $issueModel = new Issue(); + $issues = $issueModel->find(["owner_id = ? AND deleted_date IS NULL AND closed_date IS NULL", $this->id]); + foreach ($issues as $issue) { + $issue->owner_id = $userId; + $issue->save(); + } + return count($issues); + } + + public function date_picker() + { + $lang = $this->language ?: \Base::instance()->get("LANGUAGE"); + $lang = explode(',', $lang, 2)[0]; + return (object)["language" => $lang, "js" => ($lang != "en")]; + } + + /** + * Generate a password reset token and store hashed value + * @return string + */ + public function generateResetToken(): string + { + $random = random_bytes(512); + $token = hash("sha384", $random) . time(); + $this->reset_token = hash("sha384", $token); + return $token; + } + + /** + * Validate a plaintext password reset token + * @param string $token + * @return bool + */ + public function validateResetToken(string $token): bool + { + $ttl = \Base::instance()->get("security.reset_ttl"); + $timestampValid = substr($token, 96) > (time() - $ttl); + $tokenValid = hash("sha384", $token) == $this->reset_token; + return $timestampValid && $tokenValid; + } } diff --git a/app/model/user/group.php b/app/model/user/group.php index bc9762d2..b80ceaaa 100644 --- a/app/model/user/group.php +++ b/app/model/user/group.php @@ -10,50 +10,50 @@ * @property int $group_id * @property int $manager */ -class Group extends \Model { - - protected $_table_name = "user_group"; - - /** - * Get complete group list for user - * @param int $user_id - * @return array - */ - public static function getUserGroups($user_id = null) { - $f3 = \Base::instance(); - $db = $f3->get("db.instance"); - - if($user_id === null) { - $user_id = $f3->get("user.id"); - } - - $query_groups = "SELECT u.id, u.name, u.username - FROM user u - JOIN user_group g ON u.id = g.group_id - WHERE g.user_id = :user AND u.deleted_date IS NULL ORDER BY u.name"; - - $result = $db->exec($query_groups, array(":user" => $user_id)); - return $result; - } - - /** - * Check if a user is in a group - * @param int $group_id - * @param int $user_id - * @return bool - */ - public static function userIsInGroup($group_id, $user_id = null) { - $f3 = \Base::instance(); - - if($user_id === null) { - $user_id = $f3->get("user.id"); - } - - $group = new static(); - $group->load(array('user_id = ? AND group_id = ?', $user_id, $group_id)); - - return $group->id ? true : false; - } - +class Group extends \Model +{ + protected $_table_name = "user_group"; + + /** + * Get complete group list for user + * @param int $user_id + * @return array + */ + public static function getUserGroups($user_id = null): array + { + $f3 = \Base::instance(); + $db = $f3->get("db.instance"); + + if ($user_id === null) { + $user_id = $f3->get("user.id"); + } + + $query_groups = "SELECT u.id, u.name, u.username + FROM user u + JOIN user_group g ON u.id = g.group_id + WHERE g.user_id = :user AND u.deleted_date IS NULL ORDER BY u.name"; + + $result = $db->exec($query_groups, [":user" => $user_id]); + return $result; + } + + /** + * Check if a user is in a group + * @param int $group_id + * @param int $user_id + * @return bool + */ + public static function userIsInGroup(int $group_id, $user_id = null): bool + { + $f3 = \Base::instance(); + + if ($user_id === null) { + $user_id = $f3->get("user.id"); + } + + $group = new static(); + $group->load(['user_id = ? AND group_id = ?', $user_id, $group_id]); + + return $group->id ? true : false; + } } - diff --git a/app/plugin.php b/app/plugin.php index 76741ebb..121b1eb5 100644 --- a/app/plugin.php +++ b/app/plugin.php @@ -1,123 +1,132 @@ addHook($hook, $action); - return $this; - } - - /** - * Add a link to the navigation bar - * @param string $href - * @param string $title - * @param string $match Optional regex, will highlight if the URL matches - * @param string $location Optional location, valid values: 'root', 'user', 'new', 'browse' - * @return Plugin - */ - final protected function _addNav($href, $title, $match = null, $location = 'root') { - \Helper\Plugin::instance()->addNavItem($href, $title, $match, $location); - return $this; - } - - /** - * Include JavaScript code or file - * @param string $value Code or file path - * @param string $type Whether to include as "code" or a "file" - * @param string $match Optional regex, will include if the URL matches - * @return Plugin - */ - final protected function _addJs($value, $type = "code", $match = null) { - if($type == "file") { - \Helper\Plugin::instance()->addJsFile($value, $match); - } else { - \Helper\Plugin::instance()->addJsCode($value, $match); - } - return $this; - } - - /** - * Get current time and date in a MySQL NOW() format - * @param boolean $time Whether to include the time in the string - * @return string - */ - final public function now($time = true) { - return $time ? date("Y-m-d H:i:s") : date("Y-m-d"); - } - - /** - * Get plugin's metadata including author, package, version, etc. - * @return array - */ - final public function _meta() { - if($this->_meta) { - return $this->_meta; - } - - // Parse class file for phpDoc comments - $obj = new ReflectionClass($this); - $str = file_get_contents($obj->getFileName()); - preg_match_all("/\\s+@(package|author|version) (.+)/m", $str, $matches, PREG_SET_ORDER); - - // Build meta array from phpDoc comments - $meta = array(); - foreach($matches as $match) { - $meta[$match[1]] = trim($match[2]); - } - - $this->_meta = $meta + array("package" => str_replace(array("Plugin\\", "\\Base"), "", get_class($this)), "author" => null, "version" => null); - return $this->_meta; - } - - /** - * Get plugin's package name - * @return string - */ - final public function _package() { - $meta = $this->_meta(); - return $meta["package"]; - } - - /** - * Get plugin's version number, if any - * @return string - */ - final public function _version() { - $meta = $this->_meta(); - return $meta["version"]; - } - +abstract class Plugin extends \Prefab +{ + // Metadata container + protected $_meta; + + /** + * Initialize the plugin including any hooks + */ + abstract public function _load(); + + /** + * Runs installation code required for plugin, if any is required + * + * This method is called if _installed() returns false + */ + public function _install() + { + } + + /** + * Check if plugin is installed + * + * The return value of this method should be cached when possible + * @return bool + */ + public function _installed() + { + return true; + } + + /** + * Hook into a core feature + * This is the primary way for plugins to add functionality + * @link http://www.phproject.org/plugins.html + * @param string $hook + * @param callable $action + * @return Plugin + */ + final protected function _hook($hook, callable $action) + { + \Helper\Plugin::instance()->addHook($hook, $action); + return $this; + } + + /** + * Add a link to the navigation bar + * @param string $href + * @param string $title + * @param string $match Optional regex, will highlight if the URL matches + * @param string $location Optional location, valid values: 'root', 'user', 'new', 'browse' + * @return Plugin + */ + final protected function _addNav($href, $title, $match = null, $location = 'root') + { + \Helper\Plugin::instance()->addNavItem($href, $title, $match, $location); + return $this; + } + + /** + * Include JavaScript code or file + * @param string $value Code or file path + * @param string $type Whether to include as "code" or a "file" + * @param string $match Optional regex, will include if the URL matches + * @return Plugin + */ + final protected function _addJs($value, $type = "code", $match = null) + { + if ($type == "file") { + \Helper\Plugin::instance()->addJsFile($value, $match); + } else { + \Helper\Plugin::instance()->addJsCode($value, $match); + } + return $this; + } + + /** + * Get current time and date in a MySQL NOW() format + * @param boolean $time Whether to include the time in the string + * @return string + */ + final public function now($time = true) + { + return $time ? date("Y-m-d H:i:s") : date("Y-m-d"); + } + + /** + * Get plugin's metadata including author, package, version, etc. + * @return array + */ + final public function _meta() + { + if ($this->_meta) { + return $this->_meta; + } + + // Parse class file for phpDoc comments + $obj = new ReflectionClass($this); + $str = file_get_contents($obj->getFileName()); + preg_match_all("/\\s+@(package|author|version) (.+)/m", $str, $matches, PREG_SET_ORDER); + + // Build meta array from phpDoc comments + $meta = []; + foreach ($matches as $match) { + $meta[$match[1]] = trim($match[2]); + } + + $this->_meta = $meta + ["package" => str_replace(["Plugin\\", "\\Base"], "", get_class($this)), "author" => null, "version" => null]; + return $this->_meta; + } + + /** + * Get plugin's package name + * @return string + */ + final public function _package() + { + $meta = $this->_meta(); + return $meta["package"]; + } + + /** + * Get plugin's version number, if any + * @return string + */ + final public function _version() + { + $meta = $this->_meta(); + return $meta["version"]; + } } diff --git a/app/routes.ini b/app/routes.ini index 6a731195..ea27f262 100644 --- a/app/routes.ini +++ b/app/routes.ini @@ -5,11 +5,11 @@ GET / = Controller\Index->index GET /login = Controller\Index->login GET|POST /reset = Controller\Index->reset GET|POST /reset/forced = Controller\Index->reset_forced -GET|POST /reset/@hash = Controller\Index->reset_complete +GET|POST /reset/@token = Controller\Index->reset_complete POST /login = Controller\Index->loginpost POST /register = Controller\Index->registerpost -GET|POST /logout = Controller\Index->logout -GET|POST /ping = Controller\Index->ping +POST /logout = Controller\Index->logout +GET /opensearch.xml = Controller\Index->opensearch ; Issues GET /issues = Controller\Issues->index @@ -20,9 +20,9 @@ GET /issues/edit/@id = Controller\Issues->edit POST /issues/save = Controller\Issues->save POST /issues/bulk_update = Controller\Issues->bulk_update GET /issues/export = Controller\Issues->export -GET|POST /issues/@id = Controller\Issues->single -GET|POST /issues/delete/@id = Controller\Issues->single_delete -GET|POST /issues/undelete/@id = Controller\Issues->single_undelete +GET /issues/@id = Controller\Issues->single +POST /issues/delete/@id = Controller\Issues->single_delete +POST /issues/undelete/@id = Controller\Issues->single_undelete POST /issues/comment/save = Controller\Issues->comment_save POST /issues/comment/delete = Controller\Issues->comment_delete POST /issues/file/delete = Controller\Issues->file_delete @@ -35,12 +35,13 @@ POST /issues/@id/dependencies/delete = Controller\Issues->delete_dependency GET /issues/@id/watchers = Controller\Issues->single_watchers POST /issues/@id/watchers = Controller\Issues->add_watcher POST /issues/@id/watchers/delete = Controller\Issues->delete_watcher -GET /issues/project/@id = Controller\Issues->project_overview +GET /issues/project/@id = Controller\Issues\Project->overview +GET /issues/project/@id/files = Controller\Issues\Project->files GET /search = Controller\Issues->search POST /issues/upload = Controller\Issues->upload -GET /issues/close/@id = Controller\Issues->close -GET /issues/reopen/@id = Controller\Issues->reopen -GET /issues/copy/@id = Controller\Issues->copy +POST /issues/close/@id = Controller\Issues->close +POST /issues/reopen/@id = Controller\Issues->reopen +POST /issues/copy/@id = Controller\Issues->copy GET /issues/parent_ajax = Controller\Issues->parent_ajax ; Tags @@ -61,6 +62,7 @@ GET /atom.xml = Controller\Index->atom ; Administration GET|POST /admin = Controller\Admin->index +GET /admin/releaseCheck = Controller\Admin->releaseCheck GET /admin/@tab = Controller\Admin->@tab POST /admin/config/saveattribute = Controller\Admin->config_post_saveattribute @@ -71,40 +73,38 @@ GET /admin/users/deleted = Controller\Admin->deleted_users GET /admin/users/new = Controller\Admin->user_new POST /admin/users/save = Controller\Admin->user_save GET /admin/users/@id = Controller\Admin->user_edit -GET|POST /admin/users/@id/delete = Controller\Admin->user_delete -GET|POST /admin/users/@id/undelete = Controller\Admin->user_undelete +POST /admin/users/@id/delete = Controller\Admin->user_delete +POST /admin/users/@id/undelete = Controller\Admin->user_undelete POST /admin/groups/new = Controller\Admin->group_new -GET|POST /admin/groups/@id = Controller\Admin->group_edit -GET|POST /admin/groups/@id/delete = Controller\Admin->group_delete +GET /admin/groups/@id = Controller\Admin->group_edit +POST /admin/groups/@id/delete = Controller\Admin->group_delete POST /admin/groups/ajax = Controller\Admin->group_ajax GET|POST /admin/groups/@id/setmanager/@user_group_id = Controller\Admin->group_setmanager -GET|POST /admin/attributes/new = Controller\Admin->attribute_new -GET|POST /admin/attributes/@id = Controller\Admin->attribute_edit -GET|POST /admin/attributes/@id/delete = Controller\Admin->attribute_delete - GET|POST /admin/sprints/new = Controller\Admin->sprint_new GET|POST /admin/sprints/@id = Controller\Admin->sprint_edit ; Taskboard +GET /taskboard = Controller\Taskboard->index GET /taskboard/@id = Controller\Taskboard->index GET /taskboard/@id/@filter = Controller\Taskboard->index -GET /taskboard/@id/burndown/@tasks = Controller\Taskboard->burndown +GET /taskboard/@id/burndown/@filter = Controller\Taskboard->burndown +GET /taskboard/@id/burndownPrecise/@filter = Controller\Taskboard->burndownPrecise POST /taskboard/add = Controller\Taskboard->add POST /taskboard/edit/@id = Controller\Taskboard->edit +POST /taskboard/saveManHours = Controller\Taskboard->saveManHours ; Backlog GET /backlog = Controller\Backlog->index GET /backlog/old = Controller\Backlog->index_old POST /backlog/edit = Controller\Backlog->edit POST /backlog/sort = Controller\Backlog->sort -GET /backlog/@filter = Controller\Backlog->index -GET /backlog/@filter/@groupid = Controller\Backlog->index +GET /backlog/@filter = Controller\Backlog->redirect +GET /backlog/@filter/@groupid = Controller\Backlog->redirect ; Files GET /files/thumb/@size-@id.@format = Controller\Files->thumb -GET /files/preview/@id = Controller\Files->preview GET /files/@id/@name = Controller\Files->file GET /avatar/@size-@id.@format = Controller\Files->avatar diff --git a/app/view/admin/config.html b/app/view/admin/config.html index 2d1d998e..7794f7b6 100644 --- a/app/view/admin/config.html +++ b/app/view/admin/config.html @@ -1,275 +1,292 @@ - +

- -
-
+ + + +
- + -
+
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
-
- {{ @dict.issue_types }} - - - - - - - - - - - - - - - - -
{{ @dict.cols.id }}{{ @dict.name }}
{{ @item.id }}{{ isset(@dict[@item.name]) ? @dict[@item.name] : str_replace('_', ' ', @item.name) | esc }}
-
-
-
- {{ @dict.issue_statuses }} - - - - - - - - - - - - - - - - - - - - - - -
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.closed }}{{ @dict.taskboard_columns }}{{ @dict.taskboard_sort }}
{{ @item.id }}{{ isset(@dict[@item.name]) ? @dict[@item.name] : str_replace('_', ' ', @item.name) | esc }}{{ @item.closed ? @dict.yes : @dict.no }}{{ @item.taskboard ?: '' }}{{ @item.taskboard_sort ?: '' }}
-
-
+
+
+ {{ @dict.issue_types }} + + + + + + + + + + + + + + + + + + + +
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.role }}{{ @dict.cols.description }}
{{ @item.id }}{{ isset(@dict[@item.name]) ? @dict[@item.name] : str_replace('_', ' ', @item.name) | esc }}{{ ucfirst(isset(@dict[@item.role]) ? @dict[@item.role] : @item.role) | esc }}{{ @item.default_description | esc }}
+
+
+
+ {{ @dict.issue_statuses }} + + + + + + + + + + + + + + + + + + + + + + +
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.closed }}{{ @dict.taskboard_columns }}{{ @dict.taskboard_sort }}
{{ @item.id }}{{ isset(@dict[@item.name]) ? @dict[@item.name] : str_replace('_', ' ', @item.name) | esc }}{{ @item.closed ? @dict.yes : @dict.no }}{{ @item.taskboard ?: '' }}{{ @item.taskboard_sort ?: '' }}
+
+
-
- -

{{ @dict.parser_syntax }}

-
-
- -
-
-
-
- -
-
-

{{ @dict.advanced_options }}

-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
+
+

{{ @dict.parser_syntax }}

+
+
+ +
+
+
+
+ +
+
+

{{ @dict.advanced_options }}

+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
-
-
- {{ @dict.outgoing_mail }} -
- - -
-

{{ @dict.config_note }}: {{ @dict.package_mail_config_note, @PACKAGE | format }}

-
-
-
- {{ @dict.incoming_mail }} -
- - -
-
- - -
-
- - -
-
- - -
-

{{ @dict.config_note }}: {{ @dict.imap_settings_note, 'checkmail.php' | format }}

-
-
+
+
+ {{ @dict.outgoing_mail }} +
+ + +
+

{{ @dict.config_note }}: {{ @dict.package_mail_config_note, @PACKAGE | format }}

+
+
+
+ {{ @dict.incoming_mail }} +
+ + + {{ @dict.mailbox_help }} +
+
+ + +
+
+ + +
+
+ + +
+

{{ @dict.config_note }}: {{ @dict.imap_settings_note, 'checkmail.php' | format }}

+
+
-
-

{{ @dict.security }}

-
- - -
-
-
- -
-
+
+

{{ @dict.security }}

+
+ + +
+
+
+ +
+
+
+
+ +
+ + {{ @dict.restrict_access_detail }} + +
-

{{ @dict.general }}

-
- - -
-
- - -
-
-
- -
-
+

{{ @dict.general }}

+
+ + +
+
+ + +
+
+
+ +
+
-
+
-

{{ @dict.core }}

-
- - -
-
- - -
-
- - -
-

{{ @dict.advanced_config_note,'config' | format }}

-
+

{{ @dict.core }}

+
+ + +
+
+ + +
+
+ + +
+

{{ @dict.advanced_config_note,'config' | format }}

+
-
-
- +
+
+ - +
diff --git a/app/view/admin/groups.html b/app/view/admin/groups.html index 164f0e54..91b3fd50 100644 --- a/app/view/admin/groups.html +++ b/app/view/admin/groups.html @@ -1,70 +1,78 @@ - +
- -
- - - - - - - - - - - - - - - - - - - - - -
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.members }}{{ @dict.task_color }}
{{ @group.id }}{{ @group.name }}{{ @group.count }} #{{ @group.task_color }}
-
- -

{{ @dict.no_groups_exist }}

-
- - - + +
+ + + + + + + + + + + + + + + + + + + + + +
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.members }}{{ @dict.task_color }}
{{ @group.id }}{{ @group.name | esc }}{{ @group.count }} #{{ @group.task_color }} +
+ + + +
+
+ +

{{ @dict.no_groups_exist }}

+
+ + +
diff --git a/app/view/admin/groups/edit.html b/app/view/admin/groups/edit.html index 03360e78..2df48548 100644 --- a/app/view/admin/groups/edit.html +++ b/app/view/admin/groups/edit.html @@ -1,133 +1,153 @@ - +
- -
-
- -
-
- - -
-
-
    - -
  • -   - - -   - - -   - - - {{ @member.user_name }} -
  • -
    -
-
-
-
- - -
- -
-
- -
-
-
-
- -
-
-
-
- -
- -
-
-
-
-
- + +
+
+ +
+
+ + +
+
+
    + +
  • + + +   + + + + + + + +
    + + + +
    +
    +   + {{ @member.user_name }} +
  • +
    +
+
+
+
+ + +
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+
diff --git a/app/view/admin/index.html b/app/view/admin/index.html index 49a3d3a9..d9a79b15 100644 --- a/app/view/admin/index.html +++ b/app/view/admin/index.html @@ -1,101 +1,123 @@ - +
- + -
-
-
-

{{ @dict.users }}

-

{{ @count_user }}

-
-
-
-
-

{{ @dict.issues }}

-

{{ @count_issue }}

-
-
-
-
-

{{ @dict.comments }}

-

{{ @count_issue_comment }}

-
-
-
-
-

Updates

-

{{ @count_issue_update }}

-
-
-
+ -
-
-

{{ @dict.database }}

- {{ @dict.hostname }}: {{ @db.host }}:{{ @db.port }}
- {{ @dict.schema }}: {{ @db.name }}
- {{ @dict.username }}: {{ @db.user }}
- {{ @dict.password }}: {{ str_repeat("*", strlen(@db.pass)) }}
- {{ @dict.current_version }}: {{@version}}
-
-
-
-

{{ @dict.cache }}

-
- - -
-
-

{{ @dict.cache_mode }}: {{ @CACHE }}

- {{ @dict.timeouts }}
- {{ @dict.queries }}: {{ @cache_expire.db }}
- {{ @dict.minify }}: {{ @cache_expire.minify }}
- {{ @dict.attachments }}: {{ @cache_expire.attachments }}
-
-
-

{{ @dict.outgoing_mail }}

- - -

{{ @dict.from_address }}: {{ @mail.from }}

-
- - {{ @dict.smtp_not_enabled }} - -
-

{{ @dict.incoming_mail }}

- - - {{ @dict.hostname }}: {{ @imap.hostname }}
- {{ @dict.username }}: {{ @imap.username }}
- {{ @dict.password }}: {{ str_repeat("*", strlen(@imap.password)) }}
-
- - {{ @dict.imap_not_enabled }} - -
-
-
-

{{ @dict.miscellaneous }}

-

- {{ @dict.debug_level }}: {{ @DEBUG }}
- {{ @dict.session_lifetime }}: {{ gmdate("z\\d G\\h", @JAR.expire) }}
- {{ @dict.max_upload_size }}: {{ round(@files.maxsize/1024/1024, 2) }}MB -

-

- Gravatar
- {{ @dict.max_rating }}: {{ @gravatar.rating }}
- {{ @dict.default }}: {{ @gravatar.default }}
-

-
-
+
+
+
+

{{ @dict.users }}

+

{{ @count_user }}

+
+
+
+
+

{{ @dict.issues }}

+

{{ @count_issue }}

+
+
+
+
+

{{ @dict.comments }}

+

{{ @count_issue_comment }}

+
+
+
- +
+
+

{{ @dict.database }}

+ {{ @dict.hostname }}: {{ @db.host | esc }}:{{ @db.port | esc }}
+ {{ @dict.schema }}: {{ @db.name | esc }}
+ {{ @dict.username }}: {{ @db.user | esc }}
+ {{ @dict.password }}: ********
+ {{ @dict.current_version }}: {{@version}}
+ MySQL version: {{ @db.instance->getAttribute(\PDO::ATTR_SERVER_VERSION) }} +
+
+
+

{{ @dict.cache }}

+
+ + + + +
+

{{ @dict.cache_mode }}: {{ @CACHE }}

+ {{ @dict.timeouts }}
+ {{ @dict.queries }}: {{ @cache_expire.db }}
+ {{ @dict.minify }}: {{ @cache_expire.minify }}
+ {{ @dict.attachments }}: {{ @cache_expire.attachments }}
+
+
+

{{ @dict.outgoing_mail }}

+ + +

{{ @dict.from_address }}: {{ @mail.from }}

+
+ + {{ @dict.smtp_not_enabled }} + +
+

{{ @dict.incoming_mail }}

+ + + {{ @dict.hostname }}: {{ @imap.hostname }}
+ {{ @dict.username }}: {{ @imap.username }}
+ {{ @dict.password }}: {{ str_repeat("*", strlen(@imap.password)) }}
+
+ + {{ @dict.imap_not_enabled }} + +
+
+
+

{{ @dict.miscellaneous }}

+

+ {{ @dict.debug_level }}: {{ @DEBUG }}
+ {{ @dict.session_lifetime }}: {{ gmdate("z\\d G\\h", @JAR.expire) }}
+ {{ @dict.max_upload_size }}: {{ round(@files.maxsize/1024/1024, 2) }}MB +

+

+ Gravatar
+ {{ @dict.max_rating }}: {{ @gravatar.rating }}
+ {{ @dict.default }}: {{ @gravatar.default }}
+

+
+
+ +
+ diff --git a/app/view/admin/plugins.html b/app/view/admin/plugins.html index e04f3cd7..da7a6640 100644 --- a/app/view/admin/plugins.html +++ b/app/view/admin/plugins.html @@ -1,42 +1,42 @@ - +
- -
- - - - - - - - - - - - - - - - - - - - - - -
{{ @dict.name }}{{ @dict.cols.author }}{{ @dict.version }}
{{ @meta.package | esc }}{{ @meta.author | esc }}{{ @meta.version }} - - Details - -
-
- + +
+ + + + + + + + + + + + + + + + + + + + + + +
{{ @dict.name }}{{ @dict.cols.author }}{{ @dict.version }}
{{ @meta.package | esc }}{{ @meta.author | esc }}{{ @meta.version }} + + Details + +
+
+
diff --git a/app/view/admin/plugins/single.html b/app/view/admin/plugins/single.html index 7023ccd4..b8f31c09 100644 --- a/app/view/admin/plugins/single.html +++ b/app/view/admin/plugins/single.html @@ -1,18 +1,18 @@ - +
- - - {~ @plugin->_admin() ~} - + + + {~ @plugin->_admin() ~} +
diff --git a/app/view/admin/sprints.html b/app/view/admin/sprints.html index f430de1e..0da9e565 100644 --- a/app/view/admin/sprints.html +++ b/app/view/admin/sprints.html @@ -1,40 +1,40 @@ - +
-
- - -
-
- - - - - - - - - - - - - - - - - - - -
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.start_date }}{{ @dict.end_date }}
{{ @sprint.id }}{{ @sprint.name }}{{ @sprint.start_date }}{{ @sprint.end_date }}
-
- +
+ + +
+
+ + + + + + + + + + + + + + + + + + + +
{{ @dict.cols.id }}{{ @dict.name }}{{ @dict.start_date }}{{ @dict.end_date }}
{{ @sprint.id }}{{ @sprint.name | esc }}{{ @sprint.start_date }}{{ @sprint.end_date }}
+
+
diff --git a/app/view/admin/sprints/edit.html b/app/view/admin/sprints/edit.html index 59eebe85..dac632b1 100644 --- a/app/view/admin/sprints/edit.html +++ b/app/view/admin/sprints/edit.html @@ -1,53 +1,55 @@ - +
- -
- {{ @dict.edit_sprint }} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- - {{ @dict.cancel }} -
-
-
- + +
+ + {{ @dict.edit_sprint }} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + {{ @dict.cancel }} +
+
+ +
- - - + + + diff --git a/app/view/admin/sprints/new.html b/app/view/admin/sprints/new.html index 2e9df810..cf1f9ee2 100644 --- a/app/view/admin/sprints/new.html +++ b/app/view/admin/sprints/new.html @@ -1,53 +1,55 @@ - +
- -
- {{ @dict.new_sprint }} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- - {{ @dict.cancel }} -
-
-
- + +
+ + {{ @dict.new_sprint }} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + {{ @dict.cancel }} +
+
+ +
- - - + + + diff --git a/app/view/admin/users.html b/app/view/admin/users.html index b2063439..2c92c29f 100644 --- a/app/view/admin/users.html +++ b/app/view/admin/users.html @@ -1,119 +1,120 @@ - +
- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
{{ @dict.cols.id }}{{ @dict.username }}{{ @dict.email }}{{ @dict.name }}{{ @dict.role }}{{ @dict.task_color }}
{{ @user.id }}{{ @user.username }}{{ @user.email }}{{ @user.name }}{{ ucfirst(@user.role) }} #{{ @user.task_color }} - - - - - -
-
-

- {{ @dict.show_deactivated_users }} -

- - - + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ @dict.cols.id }}{{ @dict.username }}{{ @dict.email }}{{ @dict.name }}{{ @dict.role }}{{ @dict.task_color }}
{{ @user.id }}{{ @user.username | esc }}{{ @user.email | esc }}{{ @user.name | esc }}{{ ucfirst(@user.role) }} #{{ @user.task_color }} + + + +
+
+

+ {{ @dict.show_deactivated_users }} +

+ + +
diff --git a/app/view/admin/users/deleted.html b/app/view/admin/users/deleted.html index 14fc8abc..de8684ab 100644 --- a/app/view/admin/users/deleted.html +++ b/app/view/admin/users/deleted.html @@ -1,53 +1,61 @@ - +
- -

-  Show Active Users -

- - - - - - - - - - - - - - - - - - - - - - - - - -
{{ @dict.cols.id }}{{ @dict.username }}{{ @dict.email }}{{ @dict.name }}{{ @dict.role }}{{ @dict.task_color }}
{{ @user.id }}{{ @user.username }}{{ @user.email }}{{ @user.name }}{{ ucfirst(@user.role) }} #{{ @user.task_color }}
- - - + +

+  Back +  Deactivated Users +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ @dict.cols.id }}{{ @dict.username }}{{ @dict.email }}{{ @dict.name }}{{ @dict.role }}{{ @dict.task_color }}
{{ @user.id }}{{ @user.username | esc }}{{ @user.email | esc }}{{ @user.name | esc }}{{ ucfirst(@user.role) }} #{{ @user.task_color }} +
+ + + +
+ + +
diff --git a/app/view/admin/users/edit.html b/app/view/admin/users/edit.html index 7e408b5b..d9035486 100644 --- a/app/view/admin/users/edit.html +++ b/app/view/admin/users/edit.html @@ -1,89 +1,90 @@ - +
- -
- - - {{ @dict.new_user }} - - - {{ @dict.edit_user }} - - - -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
- -
-
-
- -
- -
-
-
-
-
- -
-
-
- -
- -
- -
-
-
-
- -
- {~ if(!empty(@this_user)) @task_color = '#' . @this_user.task_color; elseif(!empty(@POST.task_color)) @task_color = @POST.task_color; ~} - -
-
-
-
- - {{ @dict.cancel }} -
-
-
- + +
+ + + + {{ @dict.new_user }} + + + {{ @dict.edit_user }} + + + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+ +
+ +
+ +
+
+
+
+ +
+ {~ if(!empty(@this_user)) @task_color = '#' . @this_user.task_color; elseif(!empty(@POST.task_color)) @task_color = @POST.task_color; ~} + +
+
+
+
+ + {{ @dict.cancel }} +
+
+ +
diff --git a/app/view/backlog/index.html b/app/view/backlog/index.html index cdc2bb18..a89cbf5b 100644 --- a/app/view/backlog/index.html +++ b/app/view/backlog/index.html @@ -1,105 +1,143 @@ + - - + + + - - -
-
-
-
-
- {{ @dict.backlog }}  -
- - -
- - {{ @dict.add_project }} - -
-
-
    - -
  • - {{ @project.priority_name }} - - #{{ @project.id }}  - {{ @project.name | esc }} - -
  • -
    -
-
-
-
-
- -
- -
-
    - -
  • - {{ @project.priority_name }} - - #{{ @project.id }}  - {{ @project.name | esc }} - -
  • -
    -
-
-
-
-
- - - -
+ + + +
+
+
+
+ +
+
+ {{ @dict.backlog_points }}: + 0 +
+
    + + + +
+
+
+
+ +
+ + + + + + +
diff --git a/app/view/backlog/item.html b/app/view/backlog/item.html new file mode 100644 index 00000000..312c87f7 --- /dev/null +++ b/app/view/backlog/item.html @@ -0,0 +1,11 @@ +
  • + {{ @project.priority_name }} + + {{ @project.type_name }} + #{{ @project.id }}  + {{ @project.name | esc }} + + - {{ @project.size_estimate }} + + +
  • diff --git a/app/view/backlog/old.html b/app/view/backlog/old.html index 7c9c378c..867b4552 100644 --- a/app/view/backlog/old.html +++ b/app/view/backlog/old.html @@ -1,61 +1,56 @@ - - + +
    -
    -
    -

    - -  {{ @dict.show_future_sprints }} - -

    -
    -
    - {{ @dict.backlog }} -
    -
    -
      -
    -
    -

    - {{ @dict.backlog_old_help_text }} -

    -
    -
    - -
    - - - +
    +
    +

    + +  {{ @dict.show_future_sprints }} + +

    +
    +
    + {{ @dict.backlog }} +
    +
    +
      +
    +
    +

    + {{ @dict.backlog_old_help_text }} +

    +
    +
    + +
    + + + +
    diff --git a/app/view/blocks/admin/tabs.html b/app/view/blocks/admin/tabs.html index 7b5357fb..b8c959d3 100644 --- a/app/view/blocks/admin/tabs.html +++ b/app/view/blocks/admin/tabs.html @@ -1,8 +1,10 @@ diff --git a/app/view/blocks/dashboard-issue-list.html b/app/view/blocks/dashboard-issue-list.html index 5434816f..c7e1e656 100644 --- a/app/view/blocks/dashboard-issue-list.html +++ b/app/view/blocks/dashboard-issue-list.html @@ -1,45 +1,45 @@ -
    - - - {{ isset(@dict[@item.status_name]) ? @dict[@item.status_name] : str_replace('_', ' ', @item.status_name) | esc }} - - - - - - -

    - #{{ @item.id }} - {{ @item.name | esc }} - - - -

    - -

    Due {{ date("l, F j", strtotime(@item.due_date)) }}

    -
    - - - {~ - if(date('Ym', strtotime(@item.sprint_start_date)) == date('Ym', strtotime(@item.sprint_end_date))) { - @sprint_date_range = @sprint_date_range . date('j', strtotime(@item.sprint_end_date)); - } else { - @sprint_date_range = @sprint_date_range . date('M j', strtotime(@item.sprint_end_date)); - } - ~} -

    - {{ @item.sprint_name | esc }} - - — {{ @dict.under_n, '#' . @item.parent_id . ' - ' . @@item.parent_name | format }} - -

    -
    -
    -
    - -
  • {{ @dict.no_matching_issues }}
  • -
    + diff --git a/app/view/blocks/file/thumb.html b/app/view/blocks/file/thumb.html new file mode 100644 index 00000000..88b2d039 --- /dev/null +++ b/app/view/blocks/file/thumb.html @@ -0,0 +1,19 @@ +
  • + + + + + + + + + + {{ @file.filename | esc }} + + +
  • diff --git a/app/view/blocks/footer-modals.html b/app/view/blocks/footer-modals.html index 79e809e9..23290291 100644 --- a/app/view/blocks/footer-modals.html +++ b/app/view/blocks/footer-modals.html @@ -1,87 +1,73 @@ - - - diff --git a/app/view/blocks/footer-scripts.html b/app/view/blocks/footer-scripts.html index 3b47efee..1420a89c 100644 --- a/app/view/blocks/footer-scripts.html +++ b/app/view/blocks/footer-scripts.html @@ -1,18 +1,20 @@ {~ - @types = array(); - foreach(@issue_types as @type) { - if(@type.id > 0 && @type.id < 10) { - array_push(@types, @type.id); - } - } + @types = array(); + foreach(@issue_types as @type) { + if(@type.id > 0 && @type.id < 10) { + array_push(@types, @type.id); + } + } ~} - - + + + + - + - {{ @code }} + {{ @code }} diff --git a/app/view/blocks/footer.html b/app/view/blocks/footer.html index fca37a9a..68468f49 100644 --- a/app/view/blocks/footer.html +++ b/app/view/blocks/footer.html @@ -1,34 +1,39 @@ {~ - @dblog = @db.instance->log(); - @total = preg_match_all('/\([0-9\.]{3,}ms\)/', @dblog, $matches); - @cached = substr_count(@dblog, '[CACHED]'); + @dblog = @db.instance->log(); + @total = preg_match_all('/\([0-9\.]{3,}ms\)/', @dblog, $matches); + @cached = substr_count(@dblog, '[CACHED]'); ~} -
    -
    {{ @dblog }}
    +
    +
    {{ @dblog | esc }}
    - -
    -

    - - {{ @dict.n_queries,(@total - @cached) | format }} · - - - - {{ round(@pagemtime * 1000, 0) }}ms · - - {{ \Helper\View::instance()->formatFilesize(memory_get_peak_usage()) }} - - · {{ substr(@revision, 0, 7) }} - - - · {{ count(@plugins) }} {{ @dict.plugins }} - -

    -
    +
    +
    + + + +
    +
    +

    + + {{ @dict.n_queries,(@total - @cached) | format }} · + + + + {{ round(@pagemtime * 1000, 0) }}ms · + + {{ \Helper\View::instance()->formatFilesize(memory_get_peak_usage()) }} + + · {{ substr(@revision, 0, 7) }} + + + · {{ count(@plugins) }} {{ @dict.plugins }} + +

    +
    diff --git a/app/view/blocks/head.html b/app/view/blocks/head.html index 3b50774d..ae0b232c 100644 --- a/app/view/blocks/head.html +++ b/app/view/blocks/head.html @@ -3,8 +3,10 @@ -{{ empty(@title) ? @site.name : @title . ' - ' . @site.name }} + +{{ empty(@title) ? @site.name : @title . ' - ' . @site.name | esc }} - + + - + diff --git a/app/view/blocks/issue-comment.html b/app/view/blocks/issue-comment.html index ba72cac8..76c6dd0b 100644 --- a/app/view/blocks/issue-comment.html +++ b/app/view/blocks/issue-comment.html @@ -1,30 +1,39 @@
    - - - -
    -

    - {{ @comment.user_name }} - - > {{ @issue.name }} - - - × - -

    -
    {{ @comment.text, array('hashtags' => false) | parseText }}
    - - - -
    {{ @dict.attached_file }}: {{ @comment.file_filename | esc }} [{{ @dict.deleted }}]
    -
    - -
    {{ @dict.attached_file }}: {{ @comment.file_filename | esc }}
    -
    -
    -
    -

    - {{ date("D, M j, Y \\a\\t g:ia", $this->utc2local(@comment.created_date)) }} -

    -
    + + + +
    +

    + {{ @comment.user_name | esc }} + + > {{ @issue.name | esc }} + + + × + +

    +
    {{ @comment.text, array('hashtags' => false) | parseText }}
    + +
    + + +
    + + {{ @comment.file_filename | esc }} + [{{ @dict.deleted }}] +
    +
    + + + + {{ @comment.file_filename | esc }} + + +
    +
    +
    +

    + {{ date("D, M j, Y \\a\\t g:ia", $this->utc2local(@comment.created_date)) }} +

    +
    diff --git a/app/view/blocks/issue-list.html b/app/view/blocks/issue-list.html index 100b031a..df628d1b 100644 --- a/app/view/blocks/issue-list.html +++ b/app/view/blocks/issue-list.html @@ -5,93 +5,93 @@
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
    - - - - - - - - - {{ !empty(@dict.cols[@heading]) ? @dict.cols[@heading] : ucwords(str_replace(array('_', 'id'), array(' ', 'ID'), @heading)) }} - {{ !empty(@dict.cols[@heading]) ? @dict.cols[@heading] : ucwords(str_replace(array('_', 'id'), array(' ', 'ID'), @heading)) }}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + -
    + + + + + + + + + {{ !empty(@dict.cols[@heading]) ? @dict.cols[@heading] : ucwords(str_replace(array('_', 'id'), array(' ', 'ID'), @heading)) }} + {{ !empty(@dict.cols[@heading]) ? @dict.cols[@heading] : ucwords(str_replace(array('_', 'id'), array(' ', 'ID'), @heading)) }}
    +
    - + diff --git a/app/view/blocks/issue-list/bulk-update.html b/app/view/blocks/issue-list/bulk-update.html index b758760c..1e4e8d51 100644 --- a/app/view/blocks/issue-list/bulk-update.html +++ b/app/view/blocks/issue-list/bulk-update.html @@ -1,135 +1,161 @@ {{ @dict.bulk_update }} diff --git a/app/view/blocks/issue-list/filters.html b/app/view/blocks/issue-list/filters.html index 26a7a2e9..e5460cfa 100644 --- a/app/view/blocks/issue-list/filters.html +++ b/app/view/blocks/issue-list/filters.html @@ -1,177 +1,193 @@ - - - -  {{ @dict.export }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + +  {{ @dict.export }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/view/blocks/issue-list/issue.html b/app/view/blocks/issue-list/issue.html index 6880ee9b..9546892b 100644 --- a/app/view/blocks/issue-list/issue.html +++ b/app/view/blocks/issue-list/issue.html @@ -1,34 +1,34 @@ - - - - {{ @item.id }} - {{ @item.name | esc }} - {{ isset(@dict[@item.type_name]) ? @dict[@item.type_name] : str_replace('_', ' ', @item.type_name) }} - {{ isset(@dict[@item.priority_name]) ? @dict[@item.priority_name] : str_replace('_', ' ', @item.priority_name) }} - {{ isset(@dict[@item.status_name]) ? @dict[@item.status_name] : str_replace('_', ' ', @item.status_name) }} - {{ @item.parent_id ?: '' }} - - - - - - {{ @item.author_name | esc }} - - - - - {{ @item.owner_name | esc }} - - - {{ @item.owner_name | esc }} - - - {{ !empty(@item.sprint_start_date) ? date("n/j/y", strtotime(@item.sprint_start_date)) : "" }} - {{ ucwords(@dict[@item.repeat_cycle]) ?: @dict.not_repeating }} - {{ date("n/j/y", @this->utc2local(@item.created_date)) }} - {{ !empty(@item.due_date) ? date("n/j/y", strtotime(@item.due_date)) : "" }} - - {{ !empty(@item.closed_date) ? date("n/j/y", @this->utc2local(@item.closed_date)) : "" }} - + + + + {{ @item.id }} + {{ @item.name | esc }} + {{ isset(@dict[@item.type_name]) ? @dict[@item.type_name] : str_replace('_', ' ', @item.type_name) }} + {{ isset(@dict[@item.priority_name]) ? @dict[@item.priority_name] : str_replace('_', ' ', @item.priority_name) }} + {{ isset(@dict[@item.status_name]) ? @dict[@item.status_name] : str_replace('_', ' ', @item.status_name) }} + {{ @item.parent_id ?: '' }} + + + + + + {{ @item.author_name | esc }} + + + + + {{ @item.owner_name | esc }} + + + {{ @item.owner_name | esc }} + + + {{ !empty(@item.sprint_start_date) ? date("n/j/y", strtotime(@item.sprint_start_date)) : "" }} + {{ isset(@dict[@item.repeat_cycle]) ? ucwords(@dict[@item.repeat_cycle]) : @dict.not_repeating }} + {{ date("n/j/y", @this->utc2local(@item.created_date)) }} + {{ !empty(@item.due_date) ? date("n/j/y", strtotime(@item.due_date)) : "" }} + + {{ !empty(@item.closed_date) ? date("n/j/y", @this->utc2local(@item.closed_date)) : "" }} + diff --git a/app/view/blocks/navbar-public.html b/app/view/blocks/navbar-public.html index c37bc7b1..a7fbe24a 100644 --- a/app/view/blocks/navbar-public.html +++ b/app/view/blocks/navbar-public.html @@ -1,25 +1,26 @@ diff --git a/app/view/blocks/navbar.html b/app/view/blocks/navbar.html index 2c50d7a0..3370001b 100644 --- a/app/view/blocks/navbar.html +++ b/app/view/blocks/navbar.html @@ -1,159 +1,170 @@ -
    -

    - - {{ @error }} -

    -
    +
    +

    + + {{ @error }} +

    +
    -
    -

    - - {{ @success }} -

    -
    +
    +

    + + {{ @success }} +

    +
    -
    -

    - - {{ @dict.demo_notice }} -

    -
    +
    +

    + + {{ @dict.demo_notice }} +

    +
    diff --git a/app/view/error/404.html b/app/view/error/404.html index 200021f9..fc558036 100644 --- a/app/view/error/404.html +++ b/app/view/error/404.html @@ -1,23 +1,23 @@ - + - - - - - - + + + + + +
    -
    -

    {{ @ERROR.code }} Not Found

    -

    {{ @dict.error.404_text }}

    -
    - +
    +

    {{ @ERROR.code }} Not Found

    +

    {{ @dict.error.404_text }}

    +
    +
    diff --git a/app/view/error/500.html b/app/view/error/500.html index 2207a0a4..38daffa3 100644 --- a/app/view/error/500.html +++ b/app/view/error/500.html @@ -1,6 +1,6 @@

    get('ERROR.title'); ?>

    An error occurred processing your request. Please try again.

    get('DEBUG') == 3) { ?> -

    get('ERROR.text'); ?>

    -
    get('ERROR.trace')); ?>
    +

    get('ERROR.text'); ?>

    +
    get('ERROR.trace')); ?>
    diff --git a/app/view/error/general.html b/app/view/error/general.html index 75ccc6c7..f6312fdd 100644 --- a/app/view/error/general.html +++ b/app/view/error/general.html @@ -1,16 +1,16 @@ - +
    -
    -

    {{ @ERROR.code }} {{ @ERROR.text }}

    -

    An unknown error occurred.

    -
    - +
    +

    {{ @ERROR.code }} {{ @ERROR.text }}

    +

    An unknown error occurred.

    +
    +
    diff --git a/app/view/error/inline.html b/app/view/error/inline.html index 898bb550..9cbe0597 100644 --- a/app/view/error/inline.html +++ b/app/view/error/inline.html @@ -1,17 +1,17 @@
    -

    get("ERROR.code"); ?> get("ERROR.status"); ?>

    -

    get("ERROR.text"); ?>

    - get("DEBUG") >= 2) { ?> -
    - get("ERROR.trace")); ?> -
    - - get("DEBUG") >= 3) { ?> -
    get("db.instance")->log(); ?>
    - +

    get("ERROR.code"); ?> get("ERROR.status"); ?>

    +

    get("ERROR.text"); ?>

    + get("DEBUG") >= 2) { ?> +
    + get("ERROR.trace")); ?> +
    + + get("DEBUG") >= 3) { ?> +
    get("db.instance")->log(); ?>
    +
    - Fatal Error + Fatal Error
    */ ?> diff --git a/app/view/index/index.html b/app/view/index/index.html index 55c9d375..533017dc 100644 --- a/app/view/index/index.html +++ b/app/view/index/index.html @@ -1,16 +1,16 @@ - +
    - -
    -

    {{ @site.name }}

    -

    {{ @site.description | esc }}

    -
    - + +
    +

    {{ @site.name | esc }}

    +

    {{ @site.description | esc }}

    +
    +
    diff --git a/app/view/index/login.html b/app/view/index/login.html index c4c155b4..80b940e3 100644 --- a/app/view/index/login.html +++ b/app/view/index/login.html @@ -1,112 +1,114 @@ - +

    - - - {{ @site.name | esc }} - - {{ @site.name | esc }} - - - + + + {{ @site.name | esc }} + + {{ @site.name | esc }} + + +



    -
    -
    -
    -
    -

    {{ @dict.log_in }}

    -
    - - -

    {{ @login.error }}

    -
    -
    - -
    - - - - - - - - -
    -
    -
    - -
    - - - - - - - - -
    -
    -
    -
    - - {{ @dict.reset_password }} -
    -
    -
    - {~ \Helper\Plugin::instance()->callHook('render.login.after_login') ~} -
    - -
    -
    -
    -

    {{ @dict.register }}

    -
    - - -

    {{ @register.error | raw }}

    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    +
    +
    +
    + +
    +

    {{ @dict.log_in }}

    +
    + + +

    {{ @login.error }}

    +
    +
    + +
    + + + + + + + + +
    +
    +
    + +
    + + + + + + + + +
    +
    +
    +
    + + {{ @dict.reset_password }} +
    +
    + + {~ \Helper\Plugin::instance()->callHook('render.login.after_login') ~} +
    + +
    +
    + +
    +

    {{ @dict.register }}

    +
    + + +

    {{ @register.error | raw }}

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    {~ \Helper\Plugin::instance()->callHook('render.login.after_footer') ~} diff --git a/app/view/index/opensearch.xml b/app/view/index/opensearch.xml new file mode 100644 index 00000000..d5fa7555 --- /dev/null +++ b/app/view/index/opensearch.xml @@ -0,0 +1,7 @@ + + {{ @site.name | esc }} + {{ @site.description | esc }} + UTF-8 + + {{ @site.url }}search + diff --git a/app/view/index/reset.html b/app/view/index/reset.html index 856b3262..6af533ed 100644 --- a/app/view/index/reset.html +++ b/app/view/index/reset.html @@ -1,38 +1,39 @@ - +
    -
    -
    -
    -
    - {{ @dict.reset_password }} - -

    {{ @reset.success | esc }}

    -
    - -

    {{ @reset.error | esc }}

    -
    -
    - -
    - -
    -
    -
    -
    - - {{ @dict.cancel }} -
    -
    -
    -
    -
    -
    +
    +
    +
    + +
    + {{ @dict.reset_password }} + +

    {{ @reset.success | esc }}

    +
    + +

    {{ @reset.error | esc }}

    +
    +
    + +
    + +
    +
    +
    +
    + + {{ @dict.cancel }} +
    +
    +
    + +
    +
    diff --git a/app/view/index/reset_complete.html b/app/view/index/reset_complete.html index a4f78218..b776ebad 100644 --- a/app/view/index/reset_complete.html +++ b/app/view/index/reset_complete.html @@ -1,50 +1,51 @@ - +
    -
    -
    -
    -
    - {{ @dict.reset_password }} - -

    {{ @reset.success | esc }}

    -
    - -

    {{ @reset.error | esc }}

    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - - {{ @dict.cancel }} -
    -
    -
    -
    -
    -
    +
    +
    +
    + +
    + {{ @dict.reset_password }} + +

    {{ @reset.success | esc }}

    +
    + +

    {{ @reset.error | esc }}

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + + {{ @dict.cancel }} +
    +
    +
    + +
    +
    diff --git a/app/view/index/reset_forced.html b/app/view/index/reset_forced.html index 0baf54b2..eb8467ad 100644 --- a/app/view/index/reset_forced.html +++ b/app/view/index/reset_forced.html @@ -1,47 +1,48 @@ - +
    -
    -
    -
    -
    - {{ @dict.reset_password }} - -

    {{ @reset.error }}

    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - - {{ @dict.cancel }} -
    -
    -
    -
    -
    -
    +
    +
    +
    + +
    + {{ @dict.reset_password }} + +

    {{ @reset.error }}

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + + {{ @dict.cancel }} +
    +
    +
    + +
    +
    diff --git a/app/view/install.html b/app/view/install.html index 77cfe305..72d64bfe 100644 --- a/app/view/install.html +++ b/app/view/install.html @@ -1,174 +1,177 @@ - - - - {{ @dict.install_phproject }} - - + + + + {{ @dict.install_phproject }} + + -
    -
    -
    -
    -
    -
    -
    -
    {{ @dict.install_phproject }}
    -
    - - - - - -

    {{ @warning }}

    -
    -
    -
    -

    Site Configuration

    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -

    MySQL Connection

    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -

    Administrator User

    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    -
    -
    - -

    - Unable to continue installation, check the errors and try again.
    - {{ @error }} -

    -
    -
    -
    - -

    {{ @success }}

    -
    - Continue -
    -
    -
    -
    -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    +
    +
    {{ @dict.install_phproject }}
    +
    + + + + + +

    {{ @warning }}

    +
    +
    +
    +

    Site Configuration

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +

    MySQL Connection

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +

    Administrator User

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    +
    + +

    + Unable to continue installation, check the errors and try again.
    + {{ @error }} +

    +
    +
    +
    + +

    {{ @success }}

    +
    + +
    +
    +
    +
    +
    +
    +
    diff --git a/app/view/issues/edit-form.html b/app/view/issues/edit-form.html index 124742d7..d879fc00 100644 --- a/app/view/issues/edit-form.html +++ b/app/view/issues/edit-form.html @@ -1,259 +1,290 @@
    - - {{ @dict.close }} - - - - - - -

    {{ @dict.new_n, isset(@dict[@type.name]) ? @dict[@type.name] : str_replace('_', ' ', @type.name) | format }}

    - -
    {{ @dict.under_n, @parent.name | format }}
    -
    -
    - -

    {{ @dict.edit_n, isset(@dict[@type.name]) ? @dict[@type.name] : str_replace('_', ' ', @type.name) | format }}

    -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    - -
    - -
    - -
    -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    - -
    - -
    - -
    -
    -
    -
    - -
    - -
    -
    + + + {{ @dict.close }} + + + + + + +

    {{ @dict.new_n, isset(@dict[@type.name]) ? @dict[@type.name] : str_replace('_', ' ', @type.name) | format }}

    + +
    {{ @dict.under_n, @parent.name | format }}
    +
    +
    + +

    {{ @dict.edit_n, isset(@dict[@type.name]) ? @dict[@type.name] : str_replace('_', ' ', @type.name) | format }}

    +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + +
    + +
    +
    - - -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    - -
    -
    -
    -
    + + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    -
    - -
    - -
    -
    +
    + +
    + +
    +
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    +
    + +
    + + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    - - {~ \Helper\Plugin::instance()->callHook('render.issue_edit.after_fields', @issue) ~} - + + + {~ \Helper\Plugin::instance()->callHook('render.issue_edit.after_fields', @issue) ~} + + + {~ \Helper\Plugin::instance()->callHook('render.issue_create.after_fields') ~} + + -
    - -
    - -
    - -
    -
    +
    + +
    + +
    + +
    +
    - - {~ \Helper\Plugin::instance()->callHook('render.issue_edit.after_comments', @issue) ~} - -
    + + {~ \Helper\Plugin::instance()->callHook('render.issue_edit.after_comments', @issue) ~} + +
    -
    -
    -
    - - {{ @dict.cancel }} - - - - - - - - - - -
    -
    -
    - -
    -
    -
    -
    +
    +
    +
    + + {{ @dict.cancel }} + + + + + + + + + + +
    +
    +
    + +
    +
    +
    +
    diff --git a/app/view/issues/edit.html b/app/view/issues/edit.html index 455b647e..3d0b609e 100644 --- a/app/view/issues/edit.html +++ b/app/view/issues/edit.html @@ -1,83 +1,88 @@ - - - - + + + +
    - - + +
    - - - + + + + - + diff --git a/app/view/issues/file/preview/table.html b/app/view/issues/file/preview/table.html index 534e4411..89cd0388 100644 --- a/app/view/issues/file/preview/table.html +++ b/app/view/issues/file/preview/table.html @@ -1,25 +1,25 @@ - {{ @file.filename }} - + {{ @file.filename }} + - -

    - -  Download - -

    - - {~ while((@row = fgetcsv(@fh, 0, @delimiter)) !== false): ~} - - - - - - {~ endwhile ~} -
    {{ @col }}
    - {~ fclose(@fh) ~} + +

    + +  Download + +

    + + {~ while((@row = fgetcsv(@fh, 0, @delimiter)) !== false): ~} + + + + + + {~ endwhile ~} +
    {{ @col }}
    + {~ fclose(@fh) ~} diff --git a/app/view/issues/index.html b/app/view/issues/index.html index c0311561..25b40da4 100644 --- a/app/view/issues/index.html +++ b/app/view/issues/index.html @@ -1,38 +1,38 @@ - + - - -
    - -

    {{ @dict.deleted_success,@GET.deleted | format }} {{ @dict.restore_issue }}

    -
    - -
    - -
    - -
    - -
    -
    - -
    - - - - - + + +
    + +

    {{ @dict.deleted_success,intval(@GET.deleted) | format }} {{ @dict.restore_issue }}

    +
    + +
    + +
    + +
    + +
    +
    + +
    + + + + + diff --git a/app/view/issues/new.html b/app/view/issues/new.html index cee0d64f..c828b7cd 100644 --- a/app/view/issues/new.html +++ b/app/view/issues/new.html @@ -1,24 +1,24 @@ - +
    -
    -
    -

    {{ @dict.new_n, @dict.issues | format }}

    - -
    -
    - +
    +
    +

    {{ @dict.new_n, @dict.issues | format }}

    + +
    +
    +
    diff --git a/app/view/issues/project.html b/app/view/issues/project.html index 2b32aee4..9c936621 100644 --- a/app/view/issues/project.html +++ b/app/view/issues/project.html @@ -1,86 +1,125 @@ - + +
    -

    {{ @project.name | esc }} #{{ @project.id }}

    -
    -
    {{ @dict.cols.assignee }}
    -
    - - - - - {{ @project.owner_name | esc }} - - - {{ @project.owner_name | esc }} - - - - - {{ @dict.not_assigned }} - - -
    -
    - -
    {{ @project.description | parseText }}

    -
    +

    {{ @project.name | esc }} #{{ @project.id }}

    +
    +
    {{ @dict.cols.assignee }}
    +
    + + + + + {{ @project.owner_name | esc }} + + + {{ @project.owner_name | esc }} + + + + + {{ @dict.not_assigned }} + + +
    +
    + +
    {{ @project.description | parseText }}

    +
    -
    -
    -

    {{ @dict.project_overview }}

    - -
    -
    - {{ @dict.n_complete, @percentComplete.'%' | format }} -
    -
    - {{ @dict.n_complete,@stats.complete.'/'.@stats.total | format }} -
    -
    - -
    - {{ @dict.cols.hours_spent }} -
    {{ @stats.hours_spent }}
    -
    -
    - {{ @dict.cols.hours_total }} -
    {{ @stats.hours_total }}
    -
    -
    -
    +
    +
    +

    {{ @dict.project_overview }}

    + +
    +
    + {{ @dict.n_complete, @percentComplete.'%' | format }} +
    +
    + {{ @dict.n_complete,@stats.complete.'/'.@stats.total | format }} +
    +
    + +
    + {{ @dict.cols.hours_spent }} +
    {{ @stats.hours_spent }}
    +
    +
    + {{ @dict.cols.hours_total }} +
    {{ @stats.hours_total }}
    +
    +
    +
    -
    +
    -

    {{ @dict.project_tree }}

    -
    - - - - - - - - - - - - - - - - {~ @renderTree(@project) ~} - -
    {{ @dict.cols.id }}{{ @dict.cols.title }}{{ @dict.cols.type }}{{ @dict.cols.assignee }}{{ @dict.cols.author }}{{ @dict.cols.priority }}{{ @dict.cols.due_date }}{{ @dict.cols.sprint }}{{ @dict.cols.hours_spent }}
    -
    - + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + +
    diff --git a/app/view/issues/project/files.html b/app/view/issues/project/files.html new file mode 100644 index 00000000..1a7396ad --- /dev/null +++ b/app/view/issues/project/files.html @@ -0,0 +1,54 @@ + + + +
    + +
    +
    +
      + + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    {{ @dict.file_name }}{{ @dict.cols.parent }}{{ @dict.uploaded_by }}{{ @dict.upload_date }}{{ @dict.file_size }}
    + + {{ @file.filename | esc }}{{ @file.issue_id }}{{ @file.user_name | esc }}{{ date('M j, Y \a\t g:ia', @this->utc2local(strtotime(@file.created_date))) }}{{ @file.filesize | formatFilesize }}
    +
    +
    +
    +
    + +
    + {{ @dict.project_no_files_attached }} +
    +
    +
    diff --git a/app/view/issues/project/tree-item.html b/app/view/issues/project/tree-item.html index c2715b4a..8d8a905c 100644 --- a/app/view/issues/project/tree-item.html +++ b/app/view/issues/project/tree-item.html @@ -1,36 +1,36 @@ - - {{ str_repeat(" ", @level) }} - {{ @issue.id }} – - - - {{ @issue.name | esc }} - - - {{ @issue.name | esc }} - - - - {{ @issue.type_name }} - {{ @issue.owner_name }} - {{ @issue.author_name }} - {{ @issue.priority_name }} - - - - {{ date("n/j/y", strtotime(@issue.due_date)) }} - - - - {{ date("n/j/y", strtotime(@issue.sprint_end_date)) }} - - - - - - - {{ date("n/j/y", strtotime(@issue.sprint_start_date)) }} - - - {{ @issue.hours_spent }} + + {{ str_repeat(" ", @level) }} + {{ @issue.id }} – + + + {{ @issue.name | esc }} + + + {{ @issue.name | esc }} + + + + {{ @issue.type_name }} + {{ @issue.owner_name }} + {{ @issue.author_name }} + {{ @issue.priority_name }} + + + + {{ date("n/j/y", strtotime(@issue.due_date)) }} + + + + {{ date("n/j/y", strtotime(@issue.sprint_end_date)) }} + + + + + + + {{ date("n/j/y", strtotime(@issue.sprint_start_date)) }} + + + {{ @issue.hours_spent }} diff --git a/app/view/issues/project/tree.html b/app/view/issues/project/tree.html new file mode 100644 index 00000000..9de575fd --- /dev/null +++ b/app/view/issues/project/tree.html @@ -0,0 +1,20 @@ +
    + + + + + + + + + + + + + + + + {~ @renderTree(@project) ~} + +
    {{ @dict.cols.id }}{{ @dict.cols.title }}{{ @dict.cols.type }}{{ @dict.cols.assignee }}{{ @dict.cols.author }}{{ @dict.cols.priority }}{{ @dict.cols.due_date }}{{ @dict.cols.sprint }}{{ @dict.cols.hours_spent }}
    +
    diff --git a/app/view/issues/search.html b/app/view/issues/search.html index c7fc3a40..988cf526 100644 --- a/app/view/issues/search.html +++ b/app/view/issues/search.html @@ -1,33 +1,39 @@ - +
    -

    - {{ empty(@GET.closed) ? 'Include closed issues' : 'Exclude closed issues' }} -

    - -
    - -
    - -
    - -
    -
    - +

    + {{ empty(@GET.closed) ? 'Include closed issues' : 'Exclude closed issues' }} +

    + +
    + +
    + +
    + +
    +
    +
    diff --git a/app/view/issues/single.html b/app/view/issues/single.html index b68b65f6..3b72dd38 100644 --- a/app/view/issues/single.html +++ b/app/view/issues/single.html @@ -1,681 +1,750 @@ - - - - + + + +
    - - - - - -

    - {{ @dict.deleted_notice }}  - {{ @dict.restore_issue }} -

    -
    - - - - -
    -

    - {{ @issue.name | esc }} #{{ @issue.id }} -

    -
    - - - -  {{ @dict.complete }} - - - - -  {{ @dict.reopen }} - - - - -  {{ @dict.copy }} - - - - - - - - - - -  {{ @dict.edit }} - - - - - - -
    -
    -
    -
    - -

    {{ @dict.project_overview }}

    -
    - - {{ @dict.cols.author }} -
    - -

    - - - {{ @author.name | esc }} - - - {{ @author.name | esc }} - - -
    {{ @@author.username }} -

    -
    - - - {{ @dict.cols.assignee }} -
    - -

    - - - {{ @owner.name | esc }} - - - {{ @owner.name | esc }} - - -
    {{ @@owner.username }} -

    -
    -
    - -
    - -
    {{ @dict.cols.sprint }}
    -
    {{ @sprint.name | esc }} {{ date('n/j', strtotime(@sprint.start_date)) }}-{{ date('n/j', strtotime(@sprint.end_date)) }}
    -
    - -
    {{ @dict.cols.created }}
    -
    {{ date("M j, Y \\a\\t g:ia", $this->utc2local(@issue.created_date)) }}
    - -
    {{ @dict.cols.type }}
    -
    - {{ isset(@dict[@type.name]) ? @dict[@type.name] : str_replace('_', ' ', @type.name) }} -
    - -
    {{ @dict.cols.priority }}
    -
    {{ isset(@dict[@issue.priority_name]) ? @dict[@issue.priority_name] : str_replace('_', ' ', @issue.priority_name) }}
    - -
    {{ @dict.cols.status }}
    -
    {{ isset(@dict[@issue.status_name]) ? @dict[@issue.status_name] : str_replace('_', ' ', @issue.status_name) }}
    - - -
    {{ @dict.cols.repeat_cycle }}
    -
    {{ ucfirst(@issue.repeat_cycle) }}
    -
    - - -
    {{ @dict.cols.total_spent_hours }}
    -
    {{ @issue.hours_spent}}
    -
    - - -
    {{ @dict.cols.hours_total }}
    -
    {{ @issue.hours_total ?: 0 }}
    - -
    {{ @dict.cols.hours_remaining }}
    -
    {{ @issue.hours_remaining ?: 0 }}
    -
    - - -
    {{ @dict.cols.start_date }}
    -
    {{ date("D, M j, Y", strtotime(@issue.start_date)) }}
    -
    - - -
    {{ @dict.cols.due_date }}
    -
    {{ date("D, M j, Y", strtotime(@issue.due_date)) }}
    -
    -
    - - {~ \Helper\Plugin::instance()->callHook('render.issue_single.before_description', @issue) ~} - -
    -
    {{ @dict.cols.description }}:
    -
    {{ @issue.description | parseText }}
    -
    - - {~ \Helper\Plugin::instance()->callHook('render.issue_single.after_description', @issue) ~} - - -
    - -
    - {~ \Helper\Plugin::instance()->callHook('render.issue_single.before_files', @issue) ~} - - - - {~ \Helper\Plugin::instance()->callHook('render.issue_single.before_comments', @issue) ~} - -
    - - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - -
    {{ @dict.cols.id }}{{ @dict.file_name }}{{ @dict.uploaded_by }}{{ @dict.upload_date }}{{ @dict.file_size }}
    {{ @file.id }}{{ @file.filename | esc }}{{ @file.user_name | esc }}{{ date('M j, Y \a\t g:ia', $this->utc2local(strtotime(@file.created_date))) }}{{ @file.filesize | formatFilesize }}
    -
    -
    -

    -
    -
    -

    - {{ @dict.upload }} -

    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
      - - -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    - + + + + + +
    + {{ @dict.deleted_notice }}  +
    + + + +
    +
    + + + + +
    +

    + {{ @issue.name | esc }} + + - {{ @issue.size_estimate }} + + #{{ @issue.id }} +

    +
    + + +
    + + + +
    + +
    + + + +
    +
    + +  {{ @dict.copy }} + + + + + + + + + + +  {{ @dict.edit }} + + +
    + + + +
    +
    +
    +
    +
    + +

    {{ @dict.project_overview }}

    +
    + + {{ @dict.cols.author }} +
    + +

    + + + {{ @author.name | esc }} + + + {{ @author.name | esc }} + + +
    {{ @author.email ?: @author.username }} +

    +
    + + + {{ @dict.cols.assignee }} +
    + +

    + + + {{ @owner.name | esc }} + + + {{ @owner.name | esc }} + + +
    {{ @owner.email ?: @owner.username }} +

    +
    +
    + +
    + +
    {{ @dict.cols.sprint }}
    +
    {{ @sprint.name | esc }} {{ date('n/j', strtotime(@sprint.start_date)) }}-{{ date('n/j', strtotime(@sprint.end_date)) }}
    +
    + +
    {{ @dict.cols.created }}
    +
    {{ date("M j, Y \\a\\t g:ia", $this->utc2local(@issue.created_date)) }}
    + +
    {{ @dict.cols.type }}
    +
    {{ isset(@dict[@type.name]) ? @dict[@type.name] : str_replace('_', ' ', @type.name) | esc }}
    + +
    {{ @dict.cols.priority }}
    +
    {{ isset(@dict[@issue.priority_name]) ? @dict[@issue.priority_name] : str_replace('_', ' ', @issue.priority_name) | esc }}
    + +
    {{ @dict.cols.status }}
    +
    {{ isset(@dict[@issue.status_name]) ? @dict[@issue.status_name] : str_replace('_', ' ', @issue.status_name) | esc }}
    + + +
    {{ @dict.cols.repeat_cycle }}
    +
    {{ ucfirst(@issue.repeat_cycle) }}
    +
    + + +
    {{ @dict.cols.total_spent_hours }}
    +
    {{ @issue.hours_spent}}
    +
    + + +
    {{ @dict.cols.hours_total }}
    +
    {{ @issue.hours_total ?: 0 }}
    + +
    {{ @dict.cols.hours_remaining }}
    +
    {{ @issue.hours_remaining ?: 0 }}
    +
    + + +
    {{ @dict.cols.start_date }}
    +
    {{ date("D, M j, Y", strtotime(@issue.start_date)) }}
    +
    + + +
    {{ @dict.cols.due_date }}
    +
    {{ date("D, M j, Y", strtotime(@issue.due_date)) }}
    +
    +
    + + {~ \Helper\Plugin::instance()->callHook('render.issue_single.before_description', @issue) ~} + +
    +
    {{ @dict.cols.description }}:
    +
    {{ @issue.description | parseText }}
    +
    + + {~ \Helper\Plugin::instance()->callHook('render.issue_single.after_description', @issue) ~} + + +
    + +
    + {~ \Helper\Plugin::instance()->callHook('render.issue_single.before_files', @issue) ~} + + + + {~ \Helper\Plugin::instance()->callHook('render.issue_single.before_comments', @issue) ~} + +
    + +
    +
      + + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    {{ @dict.file_name }}{{ @dict.uploaded_by }}{{ @dict.upload_date }}{{ @dict.file_size }}
    + + {{ @file.filename | esc }}{{ @file.user_name | esc }}{{ date('M j, Y \a\t g:ia', @this->utc2local(strtotime(@file.created_date))) }}{{ @file.filesize | formatFilesize }}
    +
    +
    +

    +
    +
    +

    + {{ @dict.upload }} +

    +
    + +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    + +
      + + +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    -