diff --git a/.gitignore b/.gitignore index 4742eec3..3a95d3ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -.idea/ +plugin config.ini -Thumbs.db -lib/api/ -nbproject/ diff --git a/README.md b/README.md index 793d306a..32476b49 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,12 @@ +[![Gitter](https://badges.gitter.im/Join Chat.svg)](https://gitter.im/Alanaktion/phproject?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) Phproject =========== -*A lightweight project management system in PHP* - - -### Requirements -- PHP 5.3.3 or later (5.4/5.5 recommended) -- bcrypt extension -- PDO extension -- GD extension -- MySQL/MariaDB server -- Web server with support for URL rewrites (Apache .htaccess file and nginx sample configuration included) +*A high-performance project management system in PHP* ### Installation -1. Create a database on your MySQL server -2. Import the database.sql file into your new database - 1. a user named "admin" with the password "admin" will be created automatically -3. Copy config-base.ini to config.ini -4. Update config.ini with your database connection details -5. Ensure the tmp/, tmp/cache/, and log/ directories exist and are writable by the web server - -### Additional setup -- DEBUG in config.ini supports levels 0-3, with 3 being the most verbose. You should always use 0 in a production environment! -- Phproject is fast, but you can significantly increase performance by installing an op code caching layer like APC. Using APC also greatly increases the speed of temporary cached data, including minified code and common database queries. - -### Updating -Simply pulling the repo again should be safe for updates. If database.sql has been modified, you will need to merge the changes into your database as Phproject doesn't yet have a database upgrade system built in. If something breaks after updating, clearing the APC cache or the tmp/ and tmp/cache/ directories of everything except .gitignore will usually solve the problem. - -### Customization -Phproject's UI is built around [Twitter Bootstrap 3](http://getbootstrap.com), and is compatible with customized Bootstrap styles including Bootswatch. Simply change the site.theme entry in config.ini to the web path of a Bootstrap CSS file and it will replace the main CSS. Phproject's additions to the Bootstrap core are designed to add features without breaking any existing components, so unless your customized Bootstrap is very heavily modified, everything should continue to work consistently. - -To give your site a custom title and meta description, update the site.name and site.description entries in config.ini. - -You can also customize the default user image that is shown when a user does not have a Gravatar (Phproject uses mm/mysterman by default) as well as the maximum content rating of Gravatars to show (pg by default). The gravatar.default and gravatar.rating entries in config.ini can be updated to change these. +Simply clone the repo into a web accessible directory, go to the page in a browser, and fill in your database connection details. -### Internal details -Phproject uses the [Fat Free Framework](http://fatfreeframework.com/home) as it's base, allowing it to have a simple but powerful feature set without compromising performance. Every template file is compiled at run time, and only needs to be recompiled when the code is changed. Phproject includes internal caching that prevents duplicate or bulk database queries from being used, greatly improving performance on large pages with lots of data. This caching will only work if the tmp/ directory is writeable or APC is installed and configured on the server. +Detailed requirements and installation instructions are available at [phproject.org](http://www.phproject.org/install.html). ### Contributing -Phproject is maintained as an open source project for use by anyone around the world. If you find a bug or would like to see a new feature added, feel free to [create an issue on Github](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. diff --git a/app/controller/base.php b/app/controller.php similarity index 51% rename from app/controller/base.php rename to app/controller.php index aff301e9..7ed3a9db 100644 --- a/app/controller/base.php +++ b/app/controller.php @@ -1,8 +1,6 @@ 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); + } + + /** + * 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"); + } + } diff --git a/app/controller/admin.php b/app/controller/admin.php index e61542bb..595c3ad7 100644 --- a/app/controller/admin.php +++ b/app/controller/admin.php @@ -2,7 +2,7 @@ namespace Controller; -class Admin extends Base { +class Admin extends \Controller { protected $_userId; @@ -37,7 +37,7 @@ public function index($f3, $params) { $f3->set("db_stats", $db->exec("SHOW STATUS WHERE Variable_name LIKE 'Delayed_%' OR Variable_name LIKE 'Table_lock%' OR Variable_name = 'Uptime'")); - echo \Template::instance()->render("admin/index.html"); + $this->_render("admin/index.html"); } public function users($f3, $params) { @@ -47,7 +47,7 @@ public function users($f3, $params) { $users = new \Model\User(); $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'")); - echo \Template::instance()->render("admin/users.html"); + $this->_render("admin/users.html"); } public function user_edit($f3, $params) { @@ -75,7 +75,7 @@ public function user_edit($f3, $params) { } } $f3->set("this_user", $user); - echo \Template::instance()->render("admin/users/edit.html"); + $this->_render("admin/users/edit.html"); } else { $f3->error(404, "User does not exist."); } @@ -97,7 +97,7 @@ public function user_new($f3, $params) { $user->api_key = $security->salt_sha1(); $user->role = $f3->get("POST.role"); $user->task_color = ltrim($f3->get("POST.task_color"), "#"); - $user->created_date = now(); + $user->created_date = $this->now(); $user->save(); if($user->id) { $f3->reroute("/admin/users#" . $user->id); @@ -107,7 +107,7 @@ public function user_new($f3, $params) { } else { $f3->set("title", "Add User"); $f3->set("rand_color", sprintf("#%06X", mt_rand(0, 0xFFFFFF))); - echo \Template::instance()->render("admin/users/new.html"); + $this->_render("admin/users/new.html"); } } @@ -117,7 +117,7 @@ public function user_delete($f3, $params) { $user->delete(); if($f3->get("AJAX")) { - print_json(array("deleted" => 1)); + $this->_printJson(array("deleted" => 1)); } else { $f3->reroute("/admin/users"); } @@ -144,7 +144,7 @@ public function groups($f3, $params) { } $f3->set("groups", $group_array); - echo \Template::instance()->render("admin/groups.html"); + $this->_render("admin/groups.html"); } public function group_new($f3, $params) { @@ -156,7 +156,7 @@ public function group_new($f3, $params) { $group->name = $f3->get("POST.name"); $group->role = "group"; $group->task_color = sprintf("%06X", mt_rand(0, 0xFFFFFF)); - $group->created_date = now(); + $group->created_date = $this->now(); $group->save(); $f3->reroute("/admin/groups"); } else { @@ -178,7 +178,7 @@ public function group_edit($f3, $params) { $users = new \Model\User(); $f3->set("users", $users->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC"))); - echo \Template::instance()->render("admin/groups/edit.html"); + $this->_render("admin/groups/edit.html"); } public function group_delete($f3, $params) { @@ -186,7 +186,7 @@ public function group_delete($f3, $params) { $group->load($params["id"]); $group->delete(); if($f3->get("AJAX")) { - print_json(array("deleted" => 1)); + $this->_printJson(array("deleted" => 1)); } else { $f3->reroute("/admin/groups"); } @@ -223,12 +223,12 @@ public function group_ajax($f3, $params) { $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(); - print_json(array("deleted" => 1)); + $this->_printJson(array("deleted" => 1)); break; case "change_title": $group->name = trim($f3->get("POST.name")); $group->save(); - print_json(array("changed" => 1)); + $this->_printJson(array("changed" => 1)); break; } } @@ -258,7 +258,7 @@ public function attributes($f3, $params) { $attributes = new \Model\Attribute(); $f3->set("attributes", $attributes->find()); - echo \Template::instance()->render("admin/attributes.html"); + $this->_render("admin/attributes.html"); } public function attribute_new($f3, $params) { @@ -279,7 +279,7 @@ public function attribute_new($f3, $params) { } } - echo \Template::instance()->render("admin/attributes/edit.html"); + $this->_render("admin/attributes/edit.html"); } public function attribute_edit($f3, $params) { @@ -291,7 +291,7 @@ public function attribute_edit($f3, $params) { $attr->load($params["id"]); $f3->set("attribute", $attr); - echo \Template::instance()->render("admin/attributes/edit.html"); + $this->_render("admin/attributes/edit.html"); } public function sprints($f3, $params) { @@ -301,7 +301,7 @@ public function sprints($f3, $params) { $sprints = new \Model\Sprint(); $f3->set("sprints", $sprints->find()); - echo \Template::instance()->render("admin/sprints.html"); + $this->_render("admin/sprints.html"); } public function sprint_new($f3, $params) { @@ -311,7 +311,7 @@ public function sprint_new($f3, $params) { if($post = $f3->get("POST")) { if(empty($post["start_date"]) || empty($post["end_date"])) { $f3->set("error", "Start and end date are required"); - echo \Template::instance()->render("admin/sprints/new.html"); + $this->_render("admin/sprints/new.html"); return; } @@ -320,7 +320,7 @@ public function sprint_new($f3, $params) { if($end <= $start) { $f3->set("error", "End date must be after start date"); - echo \Template::instance()->render("admin/sprints/new.html"); + $this->_render("admin/sprints/new.html"); return; } @@ -333,7 +333,7 @@ public function sprint_new($f3, $params) { return; } - echo \Template::instance()->render("admin/sprints/new.html"); + $this->_render("admin/sprints/new.html"); } //new function here!!! @@ -353,7 +353,7 @@ public function sprint_edit($f3, $params) { if($post = $f3->get("POST")) { if(empty($post["start_date"]) || empty($post["end_date"])) { $f3->set("error", "Start and end date are required"); - echo \Template::instance()->render("admin/sprints/edit.html"); + $this->_render("admin/sprints/edit.html"); return; } @@ -362,7 +362,7 @@ public function sprint_edit($f3, $params) { if($end <= $start) { $f3->set("error", "End date must be after start date"); - echo \Template::instance()->render("admin/sprints/edit.html"); + $this->_render("admin/sprints/edit.html"); return; } @@ -376,13 +376,13 @@ public function sprint_edit($f3, $params) { } $f3->set("sprint", $sprint); - echo \Template::instance()->render("admin/sprints/edit.html"); + $this->_render("admin/sprints/edit.html"); } public function sprint_breaker($f3, $params) { $f3->set("title", "SprintBreaker"); $f3->set("menuitem", "admin"); - echo \Template::instance()->render("admin/sprints/breaker.html"); + $this->_render("admin/sprints/breaker.html"); } } diff --git a/app/controller/api/base.php b/app/controller/api.php similarity index 54% rename from app/controller/api/base.php rename to app/controller/api.php index 4644aba3..79a68999 100644 --- a/app/controller/api/base.php +++ b/app/controller/api.php @@ -1,11 +1,32 @@ 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"] = $f3->get("ERROR.trace"); + } + echo json_encode($out); + }); + + $this->_userId = $this->_requireAuth(); + } /** - * Require an API key. Sends an HTTP 403 if one is not supplied. + * Require an API key. Sends an HTTP 401 if one is not supplied. * @return int|bool */ protected function _requireAuth() { @@ -27,6 +48,8 @@ protected function _requireAuth() { $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)); @@ -37,20 +60,8 @@ protected function _requireAuth() { return $user->id; } else { $f3->error(401); - $f3->unload(); return false; } } - /** - * Output an error in a JSON object, stop execution - * @param integer $message - */ - protected function _error($message = 1) { - print_json(array( - "error" => $message - )); - exit(); - } - } diff --git a/app/controller/api/issues.php b/app/controller/api/issues.php index 25fffe15..97d24ae0 100644 --- a/app/controller/api/issues.php +++ b/app/controller/api/issues.php @@ -2,17 +2,15 @@ namespace Controller\Api; -class Issues extends \Controller\Api\Base { - - protected $_userId; - - public function __construct() { - $this->_userId = $this->_requireAuth(); - } - - // Converts an issue into a Redmine API-style multidimensional array - // This isn't pretty. - protected function issue_multiarray(\Model\Issue\Detail $issue) { +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! @@ -54,15 +52,7 @@ protected function issue_multiarray(\Model\Issue\Detail $issue) { // Remove redundant fields foreach($issue->schema() as $i=>$val) { - if( - substr_count($i, "type_") || - substr_count($i, "status_") || - substr_count($i, "priority_") || - substr_count($i, "author_") || - substr_count($i, "owner_") || - substr_count($i, "sprint_") || - $i == "has_due_date" - ) { + if(preg_match("/(type|status|priority|author|owner|sprint)_.+|has_due_date/", $i)) { unset($casted[$i]); } } @@ -80,10 +70,10 @@ public function get($f3, $params) { $issues = array(); foreach($result["subset"] as $iss) { - $issues[] = $this->issue_multiarray($iss); + $issues[] = $this->_issueMultiArray($iss); } - print_json(array( + $this->_printJson(array( "total_count" => $result["total"], "limit" => $result["limit"], "issues" => $issues, @@ -96,18 +86,13 @@ public function post($f3, $params) { if($_REQUEST) { // By default, use standard HTTP POST fields $post = $_REQUEST; - //$logger->write($_POST); } else { - // For Redmine compatibility, also accept a JSON object try { $post = json_decode(file_get_contents('php://input'), true); } catch (Exception $e) { - print_json(array( - "error" => "Unable to parse input" - )); - return false; + throw new Exception("Unable to parse input"); } if(!empty($post["issue"])) { @@ -144,7 +129,8 @@ public function post($f3, $params) { // Verify the required "name" field is passed if(empty($post["name"])) { - $this->_error("The 'name' value is required."); + $f3->error("The 'name' value is required."); + return; } // Verify given values are valid (types, statueses, priorities) @@ -152,28 +138,32 @@ public function post($f3, $params) { $type = new \Model\Issue\Type; $type->load($post["type_id"]); if(!$type->id) { - $this->_error("The 'type_id' field is not valid."); + $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) { - $this->_error("The 'type_id' field is not valid."); + $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) { - $this->_error("The 'status' field is not valid."); + $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) { - $this->_error("The 'priority_id' field is not valid."); + $f3->error("The 'priority_id' field is not valid."); + return; } } @@ -184,6 +174,14 @@ public function post($f3, $params) { $issue->name = trim($post["name"]); $issue->type_id = empty($post["type_id"]) ? 1 : $post["type_id"]; $issue->priority_id = empty($post["priority_id"]) ? 0 : $post["priority_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"]; } @@ -195,35 +193,60 @@ public function post($f3, $params) { } $issue->save(); - print_json(array( + // $f3->status(201); + $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 = new \Model\Issue\Detail; $issue->load($params["id"]); if($issue->id) { - print_json(array("issue" => $this->issue_multiarray($issue))); + $this->_printJson(array("issue" => $this->_issueMultiArray($issue))); } else { $f3->error(404); } } - // Update a single issue - public function single_put($f3, $params) { - // TODO: Implement dis. - $f3->error(501); - } - // Delete a single issue public function single_delete($f3, $params) { $issue = new \Model\Issue; $issue->load($params["id"]); $issue->delete(); - print_json(array( + if(!$issue->id) { + $f3->error(404); + return; + } + + $this->_printJson(array( "deleted" => $params["id"] )); } diff --git a/app/controller/api/user.php b/app/controller/api/user.php index f2f4038f..bf256060 100644 --- a/app/controller/api/user.php +++ b/app/controller/api/user.php @@ -2,29 +2,21 @@ namespace Controller\Api; -class User extends \Controller\Api\Base { - - protected $_userID; - - public function __construct() { - $this->_userID = $this->_requireAuth(); - } +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) { - // echo $man[0]->manager; - $group_id = $man[0]->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, @@ -37,33 +29,28 @@ protected function user_array(\Model\User $user) { } public function single_get($f3, $params) { - $user = new \Model\User(); - // echo $params["username"]; - $user->load($params["username"]); - // echo $user->username; + $user->load(array("username = ?", $params["username"])); if($user->id) { - print_json($this->user_array($user)); + $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) { - print_json($this->user_array($user)); - } else { - $f3->error(404); - } + $this->_printJson($this->user_array($user)); + } else { + $f3->error(404); + } } // Gets a List of uers public function get($f3, $params) { - $pagLimit = $f3->get("GET.limit") ?: 30; if($pagLimit == -1) { $pagLimit = 100000; @@ -80,12 +67,10 @@ public function get($f3, $params) { $users = array(); foreach ($result["subset"] as $user) { - // echo "hello"; $users[] = $this->user_array($user); - // # code... } - print_json(array( + $this->_printJson(array( "total_count" => $result["total"], "limit" => $result["limit"], "users" => $users, @@ -114,13 +99,10 @@ public function get_group($f3, $params) { $groups = array(); foreach ($result["subset"] as $user) { - // echo "hello"; $groups[] = $this->user_array($user); - // # code... } - - print_json(array( + $this->_printJson(array( "total_count" => $result["total"], "limit" => $result["limit"], "groups" => $groups, @@ -130,5 +112,3 @@ public function get_group($f3, $params) { } } - - diff --git a/app/controller/backlog.php b/app/controller/backlog.php index bb49d8af..03281963 100644 --- a/app/controller/backlog.php +++ b/app/controller/backlog.php @@ -2,7 +2,7 @@ namespace Controller; -class Backlog extends Base { +class Backlog extends \Controller { protected $_userId; @@ -50,7 +50,7 @@ public function index($f3, $params) { $f3->set("filter", $params["filter"]); $sprint_model = new \Model\Sprint(); - $sprints = $sprint_model->find(array("end_date >= ?", now(false)), array("order" => "start_date ASC")); + $sprints = $sprint_model->find(array("end_date >= ?", $this->now(false)), array("order" => "start_date ASC")); $issue = new \Model\Issue\Detail(); @@ -84,7 +84,7 @@ public function index($f3, $params) { $f3->set("title", "Backlog"); $f3->set("menuitem", "backlog"); - echo \Template::instance()->render("backlog/index.html"); + $this->_render("backlog/index.html"); } @@ -95,13 +95,13 @@ public function edit($f3, $params) { $issue->load($post["itemId"]); $issue->sprint_id = empty($post["reciever"]["receiverId"]) ? null : $post["reciever"]["receiverId"]; $issue->save(); - print_json($issue); + $this->_printJson($issue); } public function index_old($f3) { $sprint_model = new \Model\Sprint(); - $sprints = $sprint_model->find(array("end_date < ?", now(false)), array("order" => "start_date DESC")); + $sprints = $sprint_model->find(array("end_date < ?", $this->now(false)), array("order" => "start_date DESC")); $issue = new \Model\Issue\Detail(); @@ -115,6 +115,6 @@ public function index_old($f3) { $f3->set("title", "Backlog"); $f3->set("menuitem", "backlog"); - echo \Template::instance()->render("backlog/old.html"); + $this->_render("backlog/old.html"); } } diff --git a/app/controller/files.php b/app/controller/files.php index f44ab43b..03824b89 100644 --- a/app/controller/files.php +++ b/app/controller/files.php @@ -2,9 +2,68 @@ namespace Controller; -class Files extends Base { +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; + } 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"]); @@ -17,31 +76,26 @@ public function thumb($f3, $params) { $bg = 0xFFFFFF; // Generate thumbnail of image file - if(substr($file->content_type, 0, 5) == "image") { - if(is_file($f3->get("ROOT") . "/" . $file->disk_filename)) { - $img = new \Helper\Image($file->disk_filename, null, $f3->get("ROOT") . "/"); + 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", null, $f3->get("ROOT") . "/"); + $img = new \Helper\Image("img/404.png"); } $img->resize($params["size"], $params["size"]); $fg = 0xFFFFFF; $bg = 0x000000; - - // Ensure proper content-type for JPEG images - if($params["format"] == "jpg") { - $params["format"] = "jpeg"; - } } // Generate thumbnail of text contents - elseif(substr($file->content_type, 0, 4) == "text") { + elseif(substr($file->content_type, 0, 5) == "text/") { // Get first 2KB of file - $fh = fopen($f3->get("ROOT") . "/" . $file->disk_filename, "r"); + $fh = fopen($file->disk_filename, "r"); $str = fread($fh, 2048); fclose($fh); @@ -55,14 +109,33 @@ public function thumb($f3, $params) { // Show file type icon if available if($file->content_type == "text/csv" || $file->content_type == "text/tsv") { - $icon = new \Image("img/mime/table.png", null, $f3->get("ROOT") . "/"); + $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", null, $f3->get("ROOT") . "/"); + $img = new \Helper\Image("img/mime/base.png"); $img->resize($params["size"], $params["size"]); } @@ -73,38 +146,70 @@ public function thumb($f3, $params) { $img->text($ext, 12, 0, 4, 4, $fg); } - $img->render($params["format"]); + // 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; + } 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($f3->get("ROOT") . "/uploads/avatars/" . $user->avatar_filename)) { + if($user->avatar_filename && is_file("uploads/avatars/" . $user->avatar_filename)) { // Use local file - $img = new \Image($user->avatar_filename, null, $f3->get("ROOT") . "/uploads/avatars/"); + $img = new \Image($user->avatar_filename, null, "uploads/avatars/"); $img->resize($params["size"], $params["size"]); - // Ensure proper content-type for JPEG images - if($params["format"] == "jpg") { - $params["format"] = "jpeg"; - } + // Render and output image + header("Content-type: image/" . $params["format"]); $img->render($params["format"]); } else { - // Remove avatar from user if needed and load from Gravatar - if($user->avatar_filename) { - $user->avatar_filename = null; - $user->save(); - } + // Send user to Gravatar + $f3->reroute($f3->get("SCHEME") . ":" . \Helper\View::instance()->gravatar($user->email, $params["size"]), true); + + } + } - header("Content-type: image/png"); - $file = file_get_contents("http:" . gravatar($user->email, $params["size"])); - echo $file; + 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}"); } public function file($f3, $params) { @@ -117,12 +222,13 @@ public function file($f3, $params) { } $force = true; - if(substr($file->content_type, 0, 5) == "image") { - // Don't force download on image files + if(substr($file->content_type, 0, 5) == "image" || $file->content_type == "text/plain") { + // 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(!\Web::instance()->send($f3->get("ROOT") . "/" . $file->disk_filename, null, 0, $force)) { + if(!$this->_sendFile($file->disk_filename, $file->content_type, $file->filename, $force)) { $f3->error(404); } } diff --git a/app/controller/index.php b/app/controller/index.php index fc38cf76..84baae7c 100644 --- a/app/controller/index.php +++ b/app/controller/index.php @@ -2,7 +2,7 @@ namespace Controller; -class Index extends Base { +class Index extends \Controller { public function index($f3, $params) { if($f3->get("user.id")) { @@ -10,7 +10,7 @@ public function index($f3, $params) { return $user_controller->dashboard($f3, $params); } else { if($f3->get("site.public")) { - echo \Template::instance()->render("index/index.html"); + $this->_render("index/index.html"); } else { if($f3->get("site.demo") && is_numeric($f3->get("site.demo"))) { $user = new \Model\User(); @@ -39,7 +39,7 @@ public function login($f3, $params) { if($f3->get("GET.to")) { $f3->set("to", $f3->get("GET.to")); } - echo \Template::instance()->render("index/login.html"); + $this->_render("index/login.html"); } } @@ -67,7 +67,62 @@ public function loginpost($f3, $params) { $f3->set("to", $f3->get("POST.to")); } $f3->set("login.error", "Invalid login information, try again."); - echo \Template::instance()->render("index/login.html"); + $this->_render("index/login.html"); + } + } + + public function registerpost($f3, $params) { + + // 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) { + $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(); + $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("%06X", mt_rand(0, 0xFFFFFF)); + $user->save(); + $f3->set("SESSION.phproject_user_id", $user->id); + $f3->reroute("/"); } } @@ -87,7 +142,7 @@ public function reset($f3, $params) { } } unset($user); - echo \Template::instance()->render("index/reset.html"); + $this->_render("index/reset.html"); } } @@ -99,7 +154,7 @@ public function reset_complete($f3, $params) { $user->load(array("CONCAT(password, salt) = ?", $params["hash"])); if(!$user->id || !$params["hash"]) { $f3->set("reset.error", "Invalid reset URL."); - echo \Template::instance()->render("index/reset.html"); + $this->_render("index/reset.html"); return; } if($f3->get("POST.password1")) { @@ -119,7 +174,7 @@ public function reset_complete($f3, $params) { } } $f3->set("resetuser", $user); - echo \Template::instance()->render("index/reset_complete.html"); + $this->_render("index/reset_complete.html"); } } @@ -129,4 +184,59 @@ public function logout($f3, $params) { $f3->reroute("/"); } + public function ping($f3, $params) { + 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)); + } + } + + public function atom($f3, $params) { + // 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"); + } + } diff --git a/app/controller/issues.php b/app/controller/issues.php index f35d7cd9..e41199a6 100644 --- a/app/controller/issues.php +++ b/app/controller/issues.php @@ -1,742 +1,973 @@ -_userId = $this->_requireLogin(); - } - - public function index($f3, $params) { - $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) && $issues->exists($key)) { - $filter[$key] = $val; - } - } - - // Build SQL string to use for filtering - $filter_str = ""; - foreach($filter as $i => $val) { - if($i == "name") { - $filter_str .= "`$i` LIKE '%" . addslashes($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 == "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 = '". addslashes($val) ."' AND "; - } - } else { - $filter_str .= "`$i` = '" . addslashes($val) . "' AND "; - } - } - $filter_str .= " deleted_date IS NULL "; - - - $orderby = !empty($_GET['orderby']) ? $_GET['orderby'] : "priority"; - $ascdesc = !empty($_GET['ascdesc']) && $_GET['ascdesc'] == 'asc' ? "ASC" : "DESC"; - 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 "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 "sprint": - $filter_str .= " ORDER BY sprint_start_date {$ascdesc}, priority DESC, due_date DESC "; - break; - case "priority": - default: - $filter_str .= " ORDER BY priority {$ascdesc}, due_date DESC "; - break; - } - - // 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", $type->name . "s"); - $f3->set("type", $type); - } - } else { - $f3->set("title", "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 >= ?", 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"))); - - if(empty($args["page"])) { - $args["page"] = 0; - } - $issue_page = $issues->paginate($args["page"], 50, $filter_str); - $f3->set("issues", $issue_page); - - // Set up pagination - $filter_get = http_build_query($filter); - if($issue_page["pos"] < $issue_page["count"] - 1) { - $f3->set("next", "?page=" . ($issue_page["pos"] + 1) . "&" . $filter_get); - } - if($issue_page["pos"] > 0) { - $f3->set("prev", "?page=" . ($issue_page["pos"] - 1) . "&" . $filter_get); - } - - $f3->set("show_filters", true); - $f3->set("menuitem", "browse"); - $headings = array( - "id", - "title", - "type", - "priority", - "status", - "author", - "assignee", - "sprint", - "created", - "due" - ); - $f3->set("headings", $headings); - $f3->set("ascdesc", $ascdesc); - - echo \Template::instance()->render("issues/index.html"); - } - - public function add($f3, $params) { - 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 = $f3->get("PARAMS.parent"); - $parent_issue = new \Model\Issue; - $parent_issue->load(array("id=? AND (closed_date IS NULL OR closed_date = '0000-00-00 00:00:00')", $parent)); - if($parent_issue->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 >= ?", 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", "New " . $type->name); - $f3->set("menuitem", "new"); - $f3->set("type", $type); - - echo \Template::instance()->render("issues/edit.html"); - } - - public function add_selecttype($f3, $params) { - $type = new \Model\Issue\Type; - $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); - - $f3->set("title", "New Issue"); - $f3->set("menuitem", "new"); - echo \Template::instance()->render("issues/new.html"); - } - - public function edit($f3, $params) { - $issue = new \Model\Issue; - $issue->load($f3->get("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 >= ?", 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", "Edit #" . $issue->id); - $f3->set("issue", $issue); - $f3->set("type", $type); - - if($f3->get("AJAX")) { - echo \Template::instance()->render("issues/edit-form.html"); - } else { - echo \Template::instance()->render("issues/edit.html"); - } - } - - public function close($f3, $params) { - $issue = new \Model\Issue; - $issue->load($f3->get("PARAMS.id")); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - $status = new \Model\Issue\Status; - $status->load(array("closed = ?", 1)); - $issue->status = $status->id; - $issue->closed_date = now(); - $issue->save(); - - $f3->reroute("/issues/" . $issue->id); - } - - public function reopen($f3, $params) { - $issue = new \Model\Issue; - $issue->load($f3->get("PARAMS.id")); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - $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); - } - - public function copy($f3, $params) { - $issue = new \Model\Issue; - $issue->load($f3->get("PARAMS.id")); - - if(!$issue->id) { - $f3->error(404, "Issue does not exist"); - return; - } - - try { - $new_issue = $issue->duplicate(); - } catch(Exception $e) { - print_r($f3->get("db.instance")->log()); - return; - } - - if($new_issue->id) { - $f3->reroute("/issues/" . $new_issue->id); - } else { - $f3->error(500, "Failed to duplicate issue."); - } - - } - - public function save($f3, $params) { - $post = array_map("trim", $f3->get("POST")); - - $issue = new \Model\Issue; - if(!empty($post["id"])) { - - // Updating existing issue. - $issue->load($post["id"]); - if($issue->id) { - - // Diff contents and save what's changed. - foreach($post as $i=>$val) { - if($issue->exists($i) && $i != "notify" && $issue->$i != $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 = now(); - } - } else { - $issue->closed_date = null; - } - } - - // Save to the sprint of the due date - if ($i=="due_date" && !empty($val)) { - $sprint = new \Model\Sprint; - $sprint->load(array("DATE(?) BETWEEN start_date AND end_date",$val)); - $issue->sprint_id = $sprint->id; - } - } - } - } - - 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 = now(); - $comment->save(); - $issue->update_comment = $comment->id; - } - // Save issue, send notifications (unless admin opts out) - $notify = empty($post["notify"]) ? false : true; - $issue->save($notify); - - $f3->reroute("/issues/" . $issue->id); - } else { - $f3->error(404, "This issue does not exist."); - } - - } elseif($f3->get("POST.name")) { - - // Creating new issue. - $issue->author_id = $f3->get("user.id"); - $issue->type_id = $post["type_id"]; - $issue->created_date = now(); - $issue->name = $post["name"]; - $issue->description = $post["description"]; - $issue->priority = $post["priority"]; - $issue->status = $post["status"]; - $issue->owner_id = $post["owner_id"]; - $issue->hours_total = $post["hours_remaining"]; - $issue->hours_remaining = $post["hours_remaining"]; - $issue->repeat_cycle = $post["repeat_cycle"]; - - if(!empty($post["due_date"])) { - $issue->due_date = date("Y-m-d", strtotime($post["due_date"])); - - //Save to the sprint of the due date - $sprint = new \Model\Sprint(); - $sprint->load(array("DATE(?) BETWEEN start_date AND end_date",$issue->due_date)); - $issue->sprint_id = $sprint->id; - } - if(!empty($post["parent_id"])) { - $issue->parent_id = $post["parent_id"]; - } - - // Save issue, send notifications (unless admin opts out) - $notify = empty($post["notify"]) ? false : true; - $issue->save($notify); - - if($issue->id) { - $f3->reroute("/issues/" . $issue->id); - } else { - $f3->error(500, "An error occurred saving the issue."); - } - - } else { - $f3->reroute("/issues/new/" . $post["type_id"]); - } - } - - public function single($f3, $params) { - $issue = new \Model\Issue\Detail; - $issue->load(array("id=? AND deleted_date IS NULL", $f3->get("PARAMS.id"))); - - if(!$issue->id) { - $f3->error(404); - return; - } - - $type = new \Model\Issue\Type(); - $type->load($issue->type_id); - - // Run actions if passed - $post = $f3->get("POST"); - if(!empty($post)) { - switch($post["action"]) { - case "comment": - $comment = new \Model\Issue\Comment; - if(empty($post["text"])) { - if($f3->get("AJAX")) { - print_json(array("error" => 1)); - } - else { - $f3->reroute("/issues/" . $issue->id); - } - return; - } - - $comment = new \Model\Issue\Comment(); - $comment->user_id = $this->_userId; - $comment->issue_id = $issue->id; - $comment->text = $post["text"]; - $comment->created_date = now(); - $comment->save(); - - $notification = \Helper\Notification::instance(); - $notification->issue_comment($issue->id, $comment->id); - - if($f3->get("AJAX")) { - print_json( - array( - "id" => $comment->id, - "text" => parseTextile($comment->text), - "date_formatted" => date("D, M j, Y \\a\\t g:ia", 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; - } - break; - - case "add_watcher": - $watching = new \Model\Issue\Watcher; - // Loads just in case the user is already a watcher - $watching->load(array("issue_id = ? AND user_id = ?", $issue->id, $post["user_id"])); - $watching->issue_id = $issue->id; - $watching->user_id = $post["user_id"]; - $watching->save(); - - if($f3->get("AJAX")) - return; - break; - - case "remove_watcher": - $watching = new \Model\Issue\Watcher; - $watching->load(array("issue_id = ? AND user_id = ?", $issue->id, $post["user_id"])); - $watching->delete(); - - if($f3->get("AJAX")) - return; - break; - } - } - - $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(); - $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("hierarchy", $issue->hierarchy()); - $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 >= ?", 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"))); - - echo \Template::instance()->render("issues/single.html"); - - } - - 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); - - print_json(array( - "total" => count($updates), - "html" => clean_json(\Template::instance()->render("issues/single/history.html")) - )); - } - - public function single_related($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - - if($issue->id) { - $f3->set("issue", $issue); - $issues = new \Model\Issue\Detail; - if($f3->get("issue_type.project") == $issue->type_id || !$issue->parent_id) { - $found_issues = $issues->find(array("parent_id = ? AND deleted_date IS NULL", $issue->id), array('order' => "status_closed, priority DESC, due_date")); - $f3->set("issues", $found_issues); - $f3->set("parent", $issue); - } else { - if($issue->parent_id) { - $found_issues = $issues->find(array("(parent_id = ? OR parent_id = ?) AND parent_id IS NOT NULL AND parent_id <> 0 AND deleted_date IS NULL AND id <> ?", $issue->parent_id, $issue->id, $issue->id), array('order' => "status_closed, priority DESC, due_date")); - $f3->set("issues", $found_issues); - - $parent = new \Model\Issue; - $parent->load($issue->parent_id); - $f3->set("parent", $parent); - } else { - $f3->set("issues", array()); - } - } - - print_json(array( - "total" => count($f3->get("issues")), - "html" => clean_json(\Template::instance()->render("issues/single/related.html")) - )); - } else { - $f3->error(404); - } - } - - 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"))); - - print_json(array( - "total" => count($f3->get("watchers")), - "html" => clean_json(\Template::instance()->render("issues/single/watchers.html")) - )); - } - - public function single_delete($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - $issue->delete(); - $f3->reroute("/issues?deleted={$issue->id}"); - } - - public function single_undelete($f3, $params) { - $issue = new \Model\Issue; - $issue->load($params["id"]); - $issue->deleted_date = null; - $issue->save(); - $f3->reroute("/issues/{$issue->id}"); - } - - public function file_delete($f3, $params) { - $file = new \Model\Issue\File; - $file->load($f3->get("POST.id")); - $file->delete(); - print_json($file->cast()); - } - - public function file_undelete($f3, $params) { - $file = new \Model\Issue\File; - $file->load($f3->get("POST.id")); - $file->deleted_date = null; - $file->save(); - print_json($file->cast()); - } - - public function search($f3, $params) { - $query = "%" . $f3->get("GET.q") . "%"; - if(preg_match("/^#([0-9]+)$/", $f3->get("GET.q"), $matches)){ - $f3->reroute("/issues/{$matches[1]}"); - } - - $issues = new \Model\Issue\Detail; - - $args = $f3->get("GET"); - if(empty($args["page"])) { - $args["page"] = 0; - } - - $where = "(id = ? OR name LIKE ? OR description LIKE ? - OR author_name LIKE ? OR owner_name LIKE ? - OR author_username LIKE ? OR owner_username LIKE ? - OR author_email LIKE ? OR owner_email LIKE ?) - AND deleted_date IS NULL"; - $issue_page = $issues->paginate($args["page"], 50, array($where, $f3->get("GET.q"), $query, $query, $query, $query, $query, $query, $query, $query), array("order" => "created_date DESC")); - $f3->set("issues", $issue_page); - - $f3->set("show_filters", false); - echo \Template::instance()->render("issues/search.html"); - } - - 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"]; - } - - $files = $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 = now(); - $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 = now(); - $comment->file_id = $f3->get('file_id'); - $comment->save(); - - $notification = \Helper\Notification::instance(); - $notification->issue_comment($issue->id, $comment->id); - } else { - $notification = \Helper\Notification::instance(); - $notification->issue_file($issue->id, $f3->get("file_id")); - } - - $f3->reroute("/issues/" . $issue->id); - } - - // Quick add button for adding tasks to projects - // TODO: Update code to work with frontend outside of taskboard - public function quickadd($f3, $params) { - $post = $f3->get("POST"); - - $issue = new \Model\Issue; - $issue->name = $post["title"]; - $issue->description = $post["description"]; - $issue->author_id = $this->_userId; - $issue->owner_id = $post["assigned"]; - $issue->created_date = now(); - $issue->hours_total = $post["hours"]; - if(!empty($post["dueDate"])) { - $issue->due_date = date("Y-m-d", strtotime($post["dueDate"])); - } - $issue->priority = $post["priority"]; - $issue->parent_id = $post["storyId"]; - $issue->save(); - - print_json($issue->cast() + array("taskId" => $issue->id)); - } -} +_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(); + $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 '%" . addslashes($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 == "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 = '". addslashes($val) ."' AND "; + } + } else { + $filter_str .= "`$i` = '" . addslashes($val) . "' AND "; + } + } + unset($val); + $filter_str .= " deleted_date IS NULL "; + + // Build SQL ORDER BY string + $orderby = !empty($_GET['orderby']) ? $_GET['orderby'] : "priority"; + $ascdesc = !empty($_GET['ascdesc']) && $_GET['ascdesc'] == 'asc' ? "ASC" : "DESC"; + 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 "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 "sprint": + $filter_str .= " ORDER BY sprint_start_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, $ascdesc); + + } + + /** + * Display a sortable, filterable issue list + * @param Base $f3 + * @param array $params + */ + public function index($f3, $params) { + $issues = new \Model\Issue\Detail; + + // Get filter + $args = $f3->get("GET"); + list($filter, $filter_str, $ascdesc) = $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", $type->name . "s"); + $f3->set("type", $type); + } + } else { + $f3->set("title", "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 >= ?", $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"))); + + 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($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"); + $headings = array( + "id", + "title", + "type", + "priority", + "status", + "author", + "assignee", + "sprint", + "created", + "due" + ); + $f3->set("headings", $headings); + $f3->set("ascdesc", $ascdesc); + + $f3->set("show_filters", true); + $f3->set("show_export", true); + + $this->_render("issues/index.html"); + } + + /** + * Update a list of issues + * @param Base $f3 + * @param array $params from form + */ + public function bulk_update($f3, $params) { + $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) { + + // Diff contents and save what's changed. + foreach($post as $i=>$val) { + if( + $issue->exists($i) + && $i != "id" + && $issue->$i != $val + && !empty($val) + ) { + $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; + } + } + + + + + $issue->save(); + + + } 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"]); + } + } + + /** + * Export a list of issues + * @param Base $f3 + * @param array $params + */ + public function export($f3, $params) { + $issue = new \Model\Issue\Detail; + + // Get filter data and load issues + list($filter, $filter_str, $ascdesc) = $this->_buildFilter(); + $issues = $issue->find($filter_str); + + // 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); + } + + /** + * Export a single issue + * @param Base $f3 + * @param array $params + */ + public function export_single($f3, $params) { + + } + + /** + * Create a new issue + * @param Base $f3 + * @param array $params + */ + public function add($f3, $params) { + 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 = $f3->get("PARAMS.parent"); + $parent_issue = new \Model\Issue; + $parent_issue->load(array("id=? AND (closed_date IS NULL OR closed_date = '0000-00-00 00:00:00')", $parent)); + if($parent_issue->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", "New " . $type->name); + $f3->set("menuitem", "new"); + $f3->set("type", $type); + + $this->_render("issues/edit.html"); + } + + public function add_selecttype($f3, $params) { + $type = new \Model\Issue\Type; + $f3->set("types", $type->find(null, null, $f3->get("cache_expire.db"))); + + $f3->set("title", "New Issue"); + $f3->set("menuitem", "new"); + $this->_render("issues/new.html"); + } + + public function edit($f3, $params) { + $issue = new \Model\Issue; + $issue->load($f3->get("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", "Edit #" . $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"); + } + } + + public function close($f3, $params) { + $issue = new \Model\Issue; + $issue->load($f3->get("PARAMS.id")); + + if(!$issue->id) { + $f3->error(404, "Issue does not exist"); + return; + } + + $status = new \Model\Issue\Status; + $status->load(array("closed = ?", 1)); + $issue->status = $status->id; + $issue->closed_date = $this->now(); + $issue->save(); + + $f3->reroute("/issues/" . $issue->id); + } + + public function reopen($f3, $params) { + $issue = new \Model\Issue; + $issue->load($f3->get("PARAMS.id")); + + if(!$issue->id) { + $f3->error(404, "Issue does not exist"); + return; + } + + $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); + } + + public function copy($f3, $params) { + $issue = new \Model\Issue; + $issue->load($f3->get("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 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. + foreach($post as $i=>$val) { + if( + $issue->exists($i) + && $i != "id" + && $issue->$i != $val + && (md5($val) != $post["hash_" . $i] || !isset($post["hash_" . $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; + } + } + } + } + } + + // If it's a child issue and the parent is in a sprint, + // use that sprint if another has not been set already + if(!$issue->sprint_id && $issue->parent_id) { + $parent = new \Model\Issue; + $parent->load($issue->parent_id); + if($parent->sprint_id) { + $issue->sprint_id = $parent->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(); + $issue->update_comment = $comment->id; + } + + // Save issue, optionally send notifications + $notify = !empty($post["notify"]); + $issue->save($notify); + + return $issue; + } + + /** + * Save a newly created issue + * @return Issue + */ + protected function _saveNew() { + $f3 = \Base::instance(); + $post = array_map("trim", $f3->get("POST")); + $issue = new \Model\Issue; + + // Set all supported issue fields + $issue->author_id = $f3->get("user.id"); + $issue->type_id = $post["type_id"]; + $issue->created_date = $this->now(); + $issue->name = $post["name"]; + $issue->description = $post["description"]; + $issue->priority = $post["priority"]; + $issue->status = $post["status"]; + $issue->owner_id = $post["owner_id"]; + $issue->hours_total = $post["hours_remaining"] ?: null; + $issue->hours_remaining = $post["hours_remaining"] ?: null; + $issue->repeat_cycle = $post["repeat_cycle"]; + $issue->sprint_id = $post["sprint_id"]; + + if(!empty($post["due_date"])) { + $issue->due_date = date("Y-m-d", strtotime($post["due_date"])); + + // Save to the sprint of the due date if a sprint was not specified + if(!$issue->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(!empty($post["parent_id"])) { + $issue->parent_id = $post["parent_id"]; + } + + // Save issue, optionally send notifications + $notify = !empty($post["notify"]); + $issue->save($notify); + + return $issue; + } + + public function save($f3, $params) { + 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/" . $post["type_id"]); + } + } + + public function single($f3, $params) { + $issue = new \Model\Issue\Detail; + if($f3->get("user.role") == "admin") { + $issue->load(array("id=?", $f3->get("PARAMS.id"))); + } else { + $issue->load(array("id=? AND deleted_date IS NULL", $f3->get("PARAMS.id"))); + } + + if(!$issue->id) { + $f3->error(404); + return; + } + + $type = new \Model\Issue\Type(); + $type->load($issue->type_id); + + // Run actions if passed + $post = $f3->get("POST"); + if(!empty($post)) { + switch($post["action"]) { + case "comment": + $comment = new \Model\Issue\Comment; + if(empty($post["text"])) { + if($f3->get("AJAX")) { + $this->_printJson(array("error" => 1)); + } + else { + $f3->reroute("/issues/" . $issue->id); + } + return; + } + + $comment = new \Model\Issue\Comment(); + $comment->user_id = $this->_userId; + $comment->issue_id = $issue->id; + $comment->text = $post["text"]; + $comment->created_date = $this->now(); + $comment->save(); + + $notification = \Helper\Notification::instance(); + $notification->issue_comment($issue->id, $comment->id); + + if($f3->get("AJAX")) { + $this->_printJson( + array( + "id" => $comment->id, + "text" => \Helper\View::instance()->parseTextile($comment->text), + "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; + } + break; + + case "add_watcher": + $watching = new \Model\Issue\Watcher; + // Loads just in case the user is already a watcher + $watching->load(array("issue_id = ? AND user_id = ?", $issue->id, $post["user_id"])); + $watching->issue_id = $issue->id; + $watching->user_id = $post["user_id"]; + $watching->save(); + + if($f3->get("AJAX")) + return; + break; + + case "remove_watcher": + $watching = new \Model\Issue\Watcher; + $watching->load(array("issue_id = ? AND user_id = ?", $issue->id, $post["user_id"])); + $watching->delete(); + + if($f3->get("AJAX")) + return; + break; + } + } + + $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("hierarchy", $issue->hierarchy()); + $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"); + + } + + 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")) + )); + } + + public function single_related($f3, $params) { + $issue = new \Model\Issue; + $issue->load($params["id"]); + + if($issue->id) { + $f3->set("issue", $issue); + $issues = new \Model\Issue\Detail; + if($f3->get("issue_type.project") == $issue->type_id || !$issue->parent_id) { + $searchparams = array("parent_id = ? AND deleted_date IS NULL", $issue->id); + $orderparams = array("order" => "status_closed, priority DESC, due_date"); + $found_issues = $issues->find($searchparams, $orderparams); + $f3->set("issues", $found_issues); + $f3->set("parent", $issue); + } else { + if($issue->parent_id) { + $searchparams = array("(parent_id = ? OR parent_id = ?) AND parent_id IS NOT NULL AND parent_id <> 0 AND deleted_date IS NULL AND id <> ?", $issue->parent_id, $issue->id, $issue->id); + $orderparams = array('order' => "status_closed, priority DESC, due_date"); + $found_issues = $issues->find($searchparams, $orderparams); + + $f3->set("issues", $found_issues); + + $parent = new \Model\Issue; + $parent->load($issue->parent_id); + $f3->set("parent", $parent); + } else { + $f3->set("issues", array()); + } + } + + $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); + } + } + + 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")) + )); + } + + public function single_delete($f3, $params) { + $this->_requireAdmin(); + $issue = new \Model\Issue; + $issue->load($params["id"]); + $issue->delete(); + $f3->reroute("/issues?deleted={$issue->id}"); + } + + public function single_undelete($f3, $params) { + $this->_requireAdmin(); + $issue = new \Model\Issue; + $issue->load($params["id"]); + $issue->deleted_date = null; + $issue->save(); + $f3->reroute("/issues/{$issue->id}"); + } + + public function file_delete($f3, $params) { + $file = new \Model\Issue\File; + $file->load($f3->get("POST.id")); + $file->delete(); + $this->_printJson($file->cast()); + } + + public function file_undelete($f3, $params) { + $file = new \Model\Issue\File; + $file->load($f3->get("POST.id")); + $file->deleted_date = null; + $file->save(); + $this->_printJson($file->cast()); + } + + public function search($f3, $params) { + $query = "%" . $f3->get("GET.q") . "%"; + if(preg_match("/^#([0-9]+)$/", $f3->get("GET.q"), $matches)){ + $f3->reroute("/issues/{$matches[1]}"); + } + + $issues = new \Model\Issue\Detail; + + $args = $f3->get("GET"); + if(empty($args["page"])) { + $args["page"] = 0; + } + + $where = "(id = ? OR name LIKE ? OR description LIKE ? + OR author_name LIKE ? OR owner_name LIKE ? + OR author_username LIKE ? OR owner_username LIKE ? + OR author_email LIKE ? OR owner_email LIKE ?) + AND deleted_date IS NULL"; + $issue_page = $issues->paginate($args["page"], 50, array($where, $f3->get("GET.q"), $query, $query, $query, $query, $query, $query, $query, $query), array("order" => "created_date DESC")); + $f3->set("issues", $issue_page); + + $f3->set("show_filters", false); + $this->_render("issues/search.html"); + } + + 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(); + + $notification = \Helper\Notification::instance(); + $notification->issue_comment($issue->id, $comment->id); + } else { + $notification = \Helper\Notification::instance(); + $notification->issue_file($issue->id, $f3->get("file_id")); + } + + $f3->reroute("/issues/" . $issue->id); + } + +} diff --git a/app/controller/taskboard.php b/app/controller/taskboard.php index 0f8e1d21..67ba79a5 100644 --- a/app/controller/taskboard.php +++ b/app/controller/taskboard.php @@ -2,38 +2,42 @@ namespace Controller; -class Taskboard extends Base { +class Taskboard extends \Controller { public function __construct() { $this->_userId = $this->_requireLogin(); } - public function index($f3, $params) { - - // Require a valid numeric sprint ID - if(!intval($params["id"])) { - $f3->error(404); - return; - } - - // Default to showing group tasks - if(empty($params["filter"])) { - $params["filter"] = "groups"; - } - - // Load the requested sprint - $sprint = new \Model\Sprint(); - $sprint->load($params["id"]); - if(!$sprint->id) { - $f3->error(404); - return; + /** + * 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); + } } - $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"); + return $aryRange; + } - // Get list of all users in the user's groups + /** + * 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)); @@ -49,8 +53,7 @@ public function index($f3, $params) { } elseif($params["filter"] == "me") { $filter_users = array($this->_userId); } elseif(is_numeric($params["filter"])) { - // Get a taskboard for a user or group - $user= new \Model\User(); + $user = new \Model\User(); $user->load($params["filter"]); if ($user->role == 'group') { $group_model = new \Model\User\Group(); @@ -62,24 +65,61 @@ public function index($f3, $params) { } else { $filter_users = array($params["filter"]); } + } elseif($params["filter"] == "all") { + return array(); + } else { + return array($this->_userId); } + return $filter_users; + } + + public function index($f3, $params) { + + // Require a valid numeric sprint ID + if(!intval($params["id"])) { + $f3->error(404); + return; + } + + // Default to showing group tasks + if(empty($params["filter"])) { + $params["filter"] = "groups"; + } + + // Load the requested sprint + $sprint = new \Model\Sprint(); + $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 = 1'), null, $f3->get("cache_expire.db")); + $statuses = $status->find(array('taskboard > 0'), null, $f3->get("cache_expire.db")); $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, null, $f3->get("cache_expire.db"))); + $f3->set("priorities", $priority->find(null, array("order" => "value DESC"), $f3->get("cache_expire.db"))); // Load project list $issue = new \Model\Issue\Detail(); @@ -121,7 +161,7 @@ public function index($f3, $params) { // Add current project's tasks foreach ($tasks as $task) { - if($task->parent_id == $project->id || $project->id == 0 && !$task->parent_id) { + if($task->parent_id == $project->id || $project->id == 0 && (!$task->parent_id || !in_array($task->parent_id, $parent_ids))) { $columns[$task->status][] = $task; } } @@ -142,7 +182,7 @@ public function index($f3, $params) { $f3->set("users", $users->getAll()); $f3->set("groups", $users->getAllGroups()); - echo \Template::instance()->render("taskboard/index.html"); + $this->_render("taskboard/index.html"); } public function burndown($f3, $params) { @@ -165,88 +205,69 @@ public function burndown($f3, $params) { $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')){ + // Check to see if the sprint is completed + if ($today < strtotime($sprint->end_date . ' + 1 day')) { $burnComplete = 0; - $burnDates = createDateRangeArray($sprint->start_date, $today); - $remainingDays = createDateRangeArray($today, $sprint->end_date); - } - else{ + $burnDates = $this->_createDateRangeArray($sprint->start_date, $today); + $remainingDays = $this->_createDateRangeArray($today, $sprint->end_date); + } else { $burnComplete = 1; - $burnDates = createDateRangeArray($sprint->start_date, $sprint->end_date); + $burnDates = $this->_createDateRangeArray($sprint->start_date, $sprint->end_date); $remainingDays = array(); } $burnDays = array(); $burnDatesCount = count($burnDates); - $i = 1; $db = $f3->get("db.instance"); + $query_initial = + "SELECT SUM(i.hours_total) AS remaining + FROM issue i + WHERE i.created_date < :date + AND i.id IN (" . implode(",", $visible_tasks) . ")"; + $query_daily = + "SELECT SUM(IF(f.new_value = '' OR f.new_value IS NULL, i.hours_total, 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 + 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 (" . implode(",", $visible_tasks) . ")"; + + $i = 1; + foreach($burnDates as $date) { - foreach($burnDates as $date){ - - //Get total_hours, which is the initial amount entered on each task, and cache this query - if($i == 1){ - $burnDays[$date] = - $db->exec(" - SELECT i.hours_total AS remaining - FROM issue i - WHERE i.id IN (". implode(",", $visible_tasks) .") - AND i.created_date < '" . $sprint->start_date . " 00:00:00'", // Only count tasks added before sprint - NULL, - 2678400 // 31 days - ); + // 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 - else if($i < ($burnDatesCount - 1) || $burnComplete ){ - $burnDays[$date] = $db->exec(" - SELECT IF(f.new_value = '' OR f.new_value IS NULL, i.hours_total, 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 . " 23:59:59' - 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.id IN (". implode(",", $visible_tasks) . ") - AND i.created_date < '". $date . " 23:59:59'", - NULL, - 2678400 // 31 days - ); + // 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{ - $burnDays[$date] = - $db->exec(" - SELECT IF(f.new_value = '' OR f.new_value IS NULL, i.hours_total, 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 . " 23:59:59' - 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 . " 23:59:59' - AND i.id IN (". implode(",", $visible_tasks) . ")" - ); + // 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++; } - if(!$burnComplete){//add in empty days + // Add in empty days + if(!$burnComplete) { $i = 0; foreach($remainingDays as $day) { if($i != 0){ @@ -256,27 +277,25 @@ public function burndown($f3, $params) { } } - //reformat the date and remove weekends + // Reformat the date and remove weekends $i = 0; - foreach($burnDays as $burnKey => $burnDay){ + foreach($burnDays as $burnKey => $burnDay) { $weekday = date("D", strtotime($burnKey)); $weekendDays = array("Sat","Sun"); - if( !in_array($weekday, $weekendDays) ){ + if(!in_array($weekday, $weekendDays)) { $newDate = date("M j", strtotime($burnKey)); $burnDays[$newDate] = $burnDays[$burnKey]; unset($burnDays[$burnKey]); - } - else{//remove weekend days + } else { // Remove weekend days unset($burnDays[$burnKey]); } $i++; } - $burndown = array($burnDays); - print_json($burndown); + $this->_printJson($burnDays); } public function add($f3, $params) { @@ -287,17 +306,24 @@ public function add($f3, $params) { $issue->description = $post["description"]; $issue->author_id = $this->_userId; $issue->owner_id = $post["assigned"]; - $issue->created_date = now(); - $issue->hours_total = $post["hours"]; - $issue->hours_remaining = $post["hours"]; + $issue->created_date = $this->now(); + if(!empty($post["hours"])) { + $issue->hours_total = $post["hours"]; + $issue->hours_remaining = $post["hours"]; + } if(!empty($post["dueDate"])) { $issue->due_date = date("Y-m-d", strtotime($post["dueDate"])); } + if(!empty($post["repeat_cycle"])) { + $issue->repeat_cycle = $post["repeat_cycle"]; + } $issue->priority = $post["priority"]; $issue->parent_id = $post["storyId"]; + $issue->sprint_id = $post["sprintId"]; + $issue->save(); - print_json($issue->cast() + array("taskId" => $issue->id)); + $this->_printJson($issue->cast() + array("taskId" => $issue->id)); } public function edit($f3, $params) { @@ -305,13 +331,15 @@ public function edit($f3, $params) { $issue = new \Model\Issue(); $issue->load($post["taskId"]); if(!empty($post["receiver"])) { - $issue->parent_id = $post["receiver"]["story"]; + 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 = now(); + $issue->closed_date = $this->now(); } } else { $issue->closed_date = null; @@ -333,6 +361,9 @@ public function edit($f3, $params) { } 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"]; @@ -344,18 +375,19 @@ public function edit($f3, $params) { $comment = new \Model\Issue\Comment; $comment->user_id = $this->_userId; $comment->issue_id = $issue->id; - $comment->text = $post["comment"]; - if(!empty( $post["hours_spent"])) { - $comment->text = $comment->text . " (" . $post["hours_spent"] . " hour(s) spent)"; + 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 = now(); + $comment->created_date = $this->now(); $comment->save(); $issue->update_comment = $comment->id; } $issue->save(); - print_json($issue->cast() + array("taskId" => $issue->id)); + $this->_printJson($issue->cast() + array("taskId" => $issue->id)); } } diff --git a/app/controller/user.php b/app/controller/user.php index 45d7bb6b..ba97da42 100644 --- a/app/controller/user.php +++ b/app/controller/user.php @@ -2,7 +2,7 @@ namespace Controller; -class User extends Base { +class User extends \Controller { protected $_userId; @@ -25,6 +25,8 @@ public function dashboard($f3, $params) { } $owner_ids = implode(",", $owner_ids); + + $order = "priority DESC, has_due_date ASC, due_date ASC"; $f3->set("projects", $projects->find( array( @@ -45,6 +47,10 @@ public function dashboard($f3, $params) { ) )); + $watchlist = new \Model\Issue\Watcher(); + $f3->set("watchlist", $watchlist->findby_watcher($f3, $this->_userId, $order)); + + $tasks = new \Model\Issue\Detail(); $f3->set("tasks", $tasks->find( array( @@ -61,7 +67,7 @@ public function dashboard($f3, $params) { $f3->set("sprint", $sprint); $f3->set("menuitem", "index"); - echo \Template::instance()->render("user/dashboard.html"); + $this->_render("user/dashboard.html"); } private function _loadThemes() { @@ -87,7 +93,7 @@ public function account($f3, $params) { $this->_loadThemes(); - echo \Template::instance()->render("user/account.html"); + $this->_render("user/account.html"); } public function save($f3, $params) { @@ -155,12 +161,11 @@ public function save($f3, $params) { $user->loadCurrent(); $this->_loadThemes(); - echo \Template::instance()->render("user/account.html"); + $this->_render("user/account.html"); } public function avatar($f3, $params) { $f3 = \Base::instance(); - $post = array_map("trim", $f3->get("POST")); $user = new \Model\User(); $user->load($this->_userId); @@ -183,7 +188,7 @@ public function avatar($f3, $params) { $_FILES['avatar']['name'] = $user->id . "-" . substr(sha1($user->id), 0, 4) . "." . $parts["extension"]; $f3->set("avatar_filename", $_FILES['avatar']['name']); - $files = $web->receive( + $web->receive( function($file) use($f3, $user) { if($file['size'] > $f3->get("files.maxsize")) { return false; @@ -203,7 +208,6 @@ function($file) use($f3, $user) { $cache->clear($f3->hash("GET /avatar/96/{$user->id}.png") . ".url"); $cache->clear($f3->hash("GET /avatar/128/{$user->id}.png") . ".url"); - $f3->reroute("/user"); } @@ -222,7 +226,7 @@ public function single($f3, $params) { $issues = $issue->paginate(0, 100, array("closed_date IS NULL AND deleted_date IS NULL AND (owner_id = ? OR author_id = ?)", $user->id, $user->id)); $f3->set("issues", $issues); - echo \Template::instance()->render("user/single.html"); + $this->_render("user/single.html"); } else { $f3->error(404); } diff --git a/app/controller/wiki.php b/app/controller/wiki.php deleted file mode 100644 index b3f0167f..00000000 --- a/app/controller/wiki.php +++ /dev/null @@ -1,94 +0,0 @@ -_userId = $this->_requireLogin(); - } - - public function index($f3) { - $page = new \Model\Wiki\Page; - $page->load(array("slug = ?", "index")); - - if($page->id) { - $f3->reroute("/wiki/{$page->slug}"); - } else { - $page->name = "Index"; - $page->slug = "index"; - $page->created = now(); - $page->save(); - $f3->reroute("/wiki/edit/{$page->id}"); - } - } - - public function view($f3, $params) { - $page = new \Model\Wiki\Page; - $page->load(array("slug = ?", $params["slug"])); - - if($page->id) { - $f3->set("page", $page); - echo \Template::instance()->render("wiki/page/view.html"); - } else { - $f3->reroute("/wiki/edit"); - } - } - - public function edit($f3, $params) { - // Save page on POST request - if($post = $f3->get("POST")) { - $page = new \Model\Wiki\Page; - - // Load existing page if an ID is specified - if($f3->get("POST.id")) { - $page->load($params["id"]); - } - - // Set page slug if page does not exist and no slug was passed - if(!$page->id && !$f3->get("POST.slug")) { - $page->slug = Web::instance()->slug($f3->get("POST.name")); - } elseif(!$page->id) { - $page->slug = $f3->get("POST.slug"); - } - - // Update page name and content with POSTed data - $page->name = $f3->get("POST.name"); - $old_content = $page->content; - $page->content = $f3->get("POST.content"); - - // Save the wiki page - $page->created = now(); - $page->save(); - - // Log the page update - $update = new \Model\Wiki\Page\Update; - $update->wiki_page_id = $page->id; - $update->user_id = $user_id; - $update->old_content = $old_content; - $update->new_content = $page->content; - $update->save(); - - // Redirect to the page - $f3->reroute("/wiki/{$page->slug}"); - return; - } - - // Show wiki page editor - $page = new \Model\Wiki\Page; - - // Load existing page if an ID is specified - if(!empty($params["id"])) { - $page->load($params["id"]); - } - - // List all pages for parent selection - $f3->set("pages", $page->find()); - - $f3->set("page", $page); - echo \Template::instance()->render("wiki/page/edit.html"); - } - -} diff --git a/app/dict/en.ini b/app/dict/en.ini new file mode 100644 index 00000000..b6103b22 --- /dev/null +++ b/app/dict/en.ini @@ -0,0 +1,174 @@ +; English (United States) + +; Index, login, password reset +dict.log_in="Log In" +dict.username="Username" +dict.password="Password" +dict.email="Email" +dict.email_address="Email address" + +dict.reset_password="Reset Password" +dict.new_password="New password" +dict.confirm_password="Confirm password" + +dict.cancel="Cancel" + +dict.logged_out="Logged Out" +dict.session_ended_message="Your session has ended. Please log in again." + +; Footer +dict.n_queries="{0,number,integer} queries" +dict.n_total_queries_n_cached="{0,number,integer} total queries, {1,number,integer} cached" +dict.page_generated_in_n_seconds="Page generated in {0,number} seconds" +dict.real_usage_n_bytes="Real usage: {0,number,integer} bytes" +dict.current_commit_n="Current commit: {0}" + +; Installer +dict.install_phproject="Install Phproject" + +; Navbar, basic terminology +dict.new="New" +dict.sprint="Sprint" +dict.sprints="Sprints" +dict.browse="Browse" +dict.open="Open" +dict.closed="Closed" +dict.created_by_me="Created by me" +dict.assigned_to_me="Assigned to me" +dict.issue_search="Quickly find an issue" +dict.administration="Administration" +dict.dashboard="Dashboard" +dict.issues="Issues" +dict.my_issues="My Issues" +dict.my_account="My Account" +dict.users="Users" +dict.groups="Groups" +dict.log_out="Log Out" +dict.demo_notice="This site is running in demo mode. All content is public and may be reset at any time." +dict.loading="Loading…" +dict.close="Close" + +; Errors +dict.error.loading_issue_history="Error loading issue history." +dict.error.loading_issue_watchers="Error loading issue watchers." +dict.error.loading_related_issues="Error loading related issues." + +; Dashboard +dict.taskboard="Taskboard" +dict.my_projects="My Projects" +dict.my_tasks="My Tasks" +dict.my_bugs="My Bugs" +dict.my_watchlist="My Watch List" +dict.add_project="Add Project" +dict.add_task="Add Task" +dict.add_bug="Add Bug" + +; User pages +dict.created_issues="Created Issues" +dict.assigned_issues="Assigned Issues" + +; Account +dict.name="Name" +dict.theme="Theme" +dict.task_color="Task Color" +dict.avatar="Avatar" +dict.edit_on_gravatar="Edit on Gravatar" +dict.save="Save" +dict.current_password="Current password" + +; Browse +dict.general="General" +dict.exact_match="Exact Match" +dict.submit="Submit" +dict.export="Export" +dict.go_previous="Previous" +dict.go_next="Next" + +; Issue fields +dict.cols.id="ID" +dict.cols.title="Title" +dict.cols.description="Description" +dict.cols.type="Type" +dict.cols.priority="Priority" +dict.cols.status="Status" +dict.cols.author="Author" +dict.cols.assignee="Assigned To" +dict.cols.total_spent_hours="Total Spent Hours" +dict.cols.planned_hours="Planned Hours" +dict.cols.remaining_hours="Remaining Hours" +dict.cols.due_date="Due Date" +dict.cols.repeat_cycle="Repeat Cycle" +dict.cols.parent_id="Parent ID" +dict.cols.parent="Parent" +dict.cols.sprint="Sprint" +dict.cols.created="Created" +dict.cols.due="Due" + +; Issue editing +dict.not_assigned="Not Assigned" +dict.choose_option="Choose an option" + +dict.not_repeating="Not Repeating" +dict.daily="Daily" +dict.weekly="Weekly" +dict.monthly="Monthly" + +dict.no_sprint="No Sprint" + +dict.comment="Comment" + +dict.send_notifications="Send notifications" +dict.reset="Reset" +dict.new_n="New {0}" +dict.edit_n="Edit {0}" +dict.create_n="Create {0}" +dict.save_n="Save {0}" + +; Issue page +dict.mark_complete="Mark Complete" +dict.complete="Complete" +dict.reopen="Reopen" +dict.show_on_taskboard="Show on Taskboard" +dict.copy="Copy" +dict.watch="Watch" +dict.unwatch="Unwatch" +dict.edit="Edit" +dict.delete="Delete" + +dict.files="Files" +dict.upload="Upload" +dict.upload_a_file="Upload a File" +dict.attached_file="Attached file" +dict.deleted="Deleted" + +dict.comments="Comments" +dict.history="History" +dict.watchers="Watchers" +dict.child_tasks="Child Tasks" +dict.related_tasks="Related Tasks" + +dict.write_a_comment="Write a comment…" +dict.save_comment="Save Comment" + +dict.no_history_available="No history available" +dict.add_watcher="Add Watcher" + +dict.new_sub_project="New Sub-Project" +dict.new_task="New Task" +dict.under_n="Under {0}" +dict.no_related_issues="No related issues" + +dict.copy_issue="Copy Issue" +dict.copy_issue_details="Copying this issue will duplicate it and all of its descendants. No comments, files, history, or watchers will be copied." + +dict.deleted_success="Issue #{0} successfully deleted." +dict.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." +dict.restore_issue="Restore Issue" + +dict.bulk_actions="Show Bulk Actions" +dict.bulk_update="Update All Selected Tasks" + +; Taskboard +dict.hours_remaining="Hours Remaining" +dict.ideal_hours_remaining="Ideal Hours Remaining" +dict.daily_hours_remaining="Daily Hours Remaining" diff --git a/app/dict/en_GB.ini b/app/dict/en_GB.ini new file mode 100644 index 00000000..560004ff --- /dev/null +++ b/app/dict/en_GB.ini @@ -0,0 +1,3 @@ +; English (Great Britain) + +dict.task_color="Task Colour" diff --git a/app/dict/es.ini b/app/dict/es.ini new file mode 100644 index 00000000..caf8aec9 --- /dev/null +++ b/app/dict/es.ini @@ -0,0 +1,170 @@ +; Spanish (International) + +; Index, login, password reset +dict.log_in="Iniciar sesión" +dict.username="Nombre de usuario" +dict.password="Contraseña" +dict.email="Correo" +dict.email_address="Dirección de correo electrónico" + +dict.reset_password="Restablecer contraseña" +dict.new_password="Nueva contraseña" +dict.confirm_password="Confirmar contraseña" + +dict.cancel="Cancelar" + +dict.logged_out="Desconectado" +dict.session_ended_message="Su sesión ha terminado. Por favor, inicie sesión de nuevo." + +; Footer +dict.page_generated_in_n_seconds="Página generada en {0,number} segundos" + +; Installer +dict.install_phproject="Instale Phproject" + +; Navbar, basic terminology +dict.new="Nueva" +dict.sprint="Sprint" +dict.sprints="Sprints" +dict.browse="Navegar" +dict.open="Abierto" +dict.closed="Cerrado" +dict.created_by_me="Creado por mí" +dict.assigned_to_me="Asignado a mí" +dict.issue_search="Búsqueda" +dict.administration="Administración" +dict.dashboard="Página principal" +dict.issues="Peticiónes" +dict.my_issues="Mis Peticiónes" +dict.my_account="Mis configuraciones" +dict.users="Usarios" +dict.groups="Grupos" +dict.log_out="Finalizar la sesión" +dict.demo_notice="Este sitio se ejecuta en modo de demostración. Todo el contenido es público y se puede restablecer en cualquier momento." +dict.loading="Cargando…" +dict.close="Cerrar" + +; Errors +dict.error.loading_issue_history="Se produjo un error con historia." +dict.error.loading_issue_watchers="Se produjo un error con los observadores." +dict.error.loading_related_issues="Se produjo un error con peticiónes relacionados." + +; Dashboard +dict.taskboard="Tablero de tareas" +dict.my_projects="Mis Proyectos" +dict.my_tasks="Mis Tareas" +dict.my_bugs="Mis Errores" +dict.my_watchlist="Mis lista de observar" +dict.add_project="Crear Proyecto" +dict.add_task="Crear Tarea" +dict.add_bug="Crear Error" + +; User pages +dict.created_issues="Creado peticiónes" +dict.assigned_issues="Peticiónes asignados" + +; Account +dict.name="Nombre" +dict.theme="Petición" +dict.task_color="Mi color" +dict.avatar="Imagen de mí" +dict.edit_on_gravatar="Editar en Gravatar" +dict.save="Guardar" +dict.current_password="Contraseña actual" + +; Browse +dict.general="General" +dict.exact_match="Coincidencia exacta" +dict.submit="Enviar" +dict.export="Exportación" +dict.go_previous="Anterior" +dict.go_next="Siguiente" + +; Issue fields +dict.cols.id="ID" +dict.cols.title="Título" +dict.cols.description="Descripción" +dict.cols.type="Tipo" +dict.cols.priority="Prioridad" +dict.cols.status="Estado" +dict.cols.author="Autor" +dict.cols.assignee="Cesionario" +dict.cols.total_spent_hours="Horas dedicadas" +dict.cols.planned_hours="Horas previstas" +dict.cols.remaining_hours="Horas restantes" +dict.cols.due_date="Fecha de vencimiento" +dict.cols.repeat_cycle="Ciclo de repetición" +dict.cols.parent_id="ID de Padre" +dict.cols.parent="Padre" +dict.cols.sprint="Sprint" +dict.cols.created="Creado" +dict.cols.due="Esperado" + +; Issue editing +dict.not_assigned="No asignado" +dict.choose_option="Elija una opción" + +dict.not_repeating="No repetir" +dict.daily="Diario" +dict.weekly="Semanal" +dict.monthly="Mensual" + +dict.no_sprint="No de sprint" + +dict.comment="Commentario" + +dict.send_notifications="Enviar notificaciones" +dict.reset="Reajustar" +dict.new_n="Nueva {0}" +dict.edit_n="Editar {0}" +dict.create_n="Crear {0}" +dict.save_n="Guardar {0}" + +; Issue page +dict.mark_complete="Marca Completa" +dict.complete="Completa" +dict.reopen="Reabrir" +dict.show_on_taskboard="Mostrar a Bordo Tarea" +dict.copy="Copia" +dict.watch="Ver" +dict.unwatch="Dejar de Ver" +dict.edit="Edición" +dict.delete="Borrar" + +dict.files="Archivos" +dict.upload="Cargar" +dict.upload_a_file="Cargar un archivo" +dict.attached_file="Archivo adjunto" +dict.deleted="Borrado" + +dict.comments="Comentarios" +dict.history="Historia" +dict.watchers="Observadores" +dict.child_tasks="Tareas Secundarias" +dict.related_tasks="Tareas Relacionadas" + +dict.write_a_comment="Escribir un comentario…" +dict.save_comment="Salvar Comentario" + +dict.no_history_available="Sin antecedentes disponibles" +dict.add_watcher="Añadir vigilante" + +dict.new_sub_project="Nueva sub-proyecto" +dict.new_task="Nueva tarea" +dict.under_n="Bajo {0}" +dict.no_related_issues="Peticiónes no relacionadas" + +dict.copy_issue="Petición copia" +dict.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." + +dict.deleted_success="Petición #{0} eliminado correctamente" +dict.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." +dict.restore_issue="Restaurar esta petición" + +dict.bulk_actions="Mostrar acciones a granel" +dict.bulk_update="Actualizar todas" + +; Taskboard +dict.hours_remaining="Horas Restantes" +dict.ideal_hours_remaining="Horas Ideal Restantes" +dict.daily_hours_remaining="Horas Diarias Restantes" diff --git a/app/dict/jbo.ini b/app/dict/jbo.ini new file mode 100644 index 00000000..7c8dd575 --- /dev/null +++ b/app/dict/jbo.ini @@ -0,0 +1,42 @@ +; Lojban (International) +; These are probably not accurate. + +; Index, login, password reset +dict.log_in="pilno zei vreji" + +dict.password="japyvla" +dict.email="mrilu" +dict.email_address="samjudri" + +dict.cancel="nu'osti" + +; Navbar, basic terminology +dict.new="cnino" +dict.open="kalri" +dict.closed="ganlo" +dict.log_out="cliva" +dict.close="ga'orgau" + +; Account +dict.name="cmene" +dict.save="rejgau" + +; Browse +dict.export="rejgau" +dict.go_previous="prula'i" +dict.go_next="bavla'i" + +; Issue fields +dict.cols.title="cmene" +dict.cols.description="velski" +dict.cols.type="klesi" +dict.cols.author="ci'arfi'i" +dict.cols.parent="rirni" +;dict.cols.created="detri" + +; Issue editing +dict.comment="pinka" +dict.reset="kraga'igau" +dict.copy="fukpi" +dict.edit="cusku'i" +dict.delete="vimcu" diff --git a/app/functions.php b/app/functions.php deleted file mode 100644 index d85c4b2e..00000000 --- a/app/functions.php +++ /dev/null @@ -1,254 +0,0 @@ -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); -} - -/** - * HTML escape shortcode - * @param string $str - * @return string - */ -function h($str) { - return htmlspecialchars($str); -} - -/** - * Get current time and date in a MySQL NOW() format - * @param boolean $time Determines 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"); -} - -/** - * Output object as JSON and set appropriate headers - * @param mixed $object - */ -function print_json($object) { - if(!headers_sent()) { - header("Content-type: application/json"); - } - echo json_encode($object); -} - -/** - * Clean a string for encoding in JSON - * Collapses whitespace, then trims - * @param string $string - * @return string - */ -function clean_json($string) { - return trim(preg_replace('/\s+/', ' ', $string)); -} - -/** - * Internal function used by make_clickable - * @param array $matches - * @return string - */ -function _make_url_clickable_cb($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; -} - -/** - * Internal function used by make_clickable - * @param array $m - * @return string - */ -function _make_web_ftp_clickable_cb($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; -} - -/** - * Internal function used by make_clickable - * @param array $m - * @return string - */ -function _make_email_clickable_cb($m) { - $email = $m[2].'@'.$m[3]; - return $m[1]."$email"; -} - -/** - * Converts recognized URLs and email addresses into HTML hyperlinks - * @param string $s - * @return string - */ -function make_clickable($s) { - $s = ' '.$s; - // in testing, using arrays here was found to be faster - $s = preg_replace_callback('#([\s>])([\w]+?://[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is','_make_url_clickable_cb',$s); - $s = preg_replace_callback('#([\s>])((www|ftp)\.[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is','_make_web_ftp_clickable_cb',$s); - $s = preg_replace_callback('#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})#i','_make_email_clickable_cb',$s); - - // this one is not in an array because we need it to run last, for cleanup of accidental links within links - $s = preg_replace("#(]+?>|>))]+?>([^>]+?)#i", "$1$3",$s); - $s = trim($s); - return $s; -} - -/** - * Send an email with the UTF-8 character set - * @param string $to - * @param string $subject - * @param string $body - * @return bool - */ -function utf8mail($to, $subject, $body) { - $f3 = \Base::instance(); - - // Set content-type with UTF charset - $headers = 'MIME-Version: 1.0' . "\r\n"; - $headers .= 'Content-type: text/html; charset=utf-8' . "\r\n"; - - // Add sender and recipient information - $headers .= 'To: '. $to . "\r\n"; - $headers .= 'From: '. $f3->get("mail.from") . "\r\n"; - - return mail($to, $subject, $body, $headers); -} - -/** - * 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 - */ -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; -} - -/** - * Passes a string through the Textile parser, - * also converts issue IDs and usernames to links - * @param string $str - * @param int|bool $ttl - * @return string - */ -function parseTextile($str, $ttl=false) { - if($ttl !== false) { - $cache = \Cache::instance(); - $hash = sha1($str); - - // Return value if cached - if(($val = $cache->get("$hash.tex")) !== false) { - return $val; - } - } - - // Value wasn't cached, run the parser - $tex = new \Helper\Textile\Parser(); - $tex->setDocumentType('html5') - ->setDimensionlessImages(true); - $val = $tex->parse($str); - - // Find issue IDs and convert to links - $val = preg_replace("/(?<=[\s,\(^])#([0-9]+)(?=[\s,\)\.,$])/", "#$1", $val); - - // Find usernames and replace with links - // $val = preg_replace("/(?<=\s)@([a-z0-9_-]+)(?=\s)/i", " @$1 ", $val); - - // Convert URLs to links - $val = make_clickable($val); - - // Cache the value if $ttl was given - if($ttl !== false) { - $cache->set("$hash.tex", $val, $ttl); - } - - // Return the parsed value - return $val; -} - -/** - * Get a human-readable file size - * @param int $filesize - * @return string - */ -function format_filesize($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"; - } -} - -/** - * Convert a UTC timestamp to local time - * @param int $timestamp - * @return int - */ -function utc2local($timestamp = null) { - 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); - } - - return $timestamp + $offset; -} diff --git a/app/helper/diff.php b/app/helper/diff.php index 825ae064..3cb56d1a 100644 --- a/app/helper/diff.php +++ b/app/helper/diff.php @@ -173,7 +173,6 @@ public function getGroupedOpcodes() return $this->groupedCodes; } - // require_once dirname(__FILE__).'/Diff/SequenceMatcher.php'; $sequenceMatcher = new \Helper\Diff\Sequencematcher($this->a, $this->b, null, $this->options); $this->groupedCodes = $sequenceMatcher->getGroupedOpcodes($this->options['context']); return $this->groupedCodes; diff --git a/app/helper/image.php b/app/helper/image.php index bb5fbba9..136d4072 100644 --- a/app/helper/image.php +++ b/app/helper/image.php @@ -4,8 +4,15 @@ class Image extends \Image { - protected $colors = array(); - public $last_data; + protected $last_data; + + /** + * Get the last GD return value, generally from imagettftext + * @return mixed last_data + */ + function getLastData() { + return $this->last_data; + } /** * Create a new blank canvase @@ -38,7 +45,7 @@ function text($text, $size = 9.0, $angle = 0, $x = 0, $y = 0, $color = 0x000000, $f3 = \Base::instance(); - $font = $f3->get("ROOT") . "/fonts/" . $font; + $font = "fonts/" . $font; if(!is_file($font)) { $f3->error(500, "Font file not found"); return false; @@ -68,51 +75,6 @@ function text($text, $size = 9.0, $angle = 0, $x = 0, $y = 0, $color = 0x000000, return $this->save(); } - /** - * Render fully justified and wrapped text - * @param string $text - * @param float $size - * @param integer $left - * @param integer $top - * @param hex $color - * @param string $font - * @param integer $max_width - * @return Image - */ - function textwrap($text, $size = 9.0, $left = 0, $top = 0, $color = 0x000000, $font = "opensans-regular.ttf", $max_width = 0) { - $f3 = \Base::instance(); - - $color = $this->rgb($color); - $color_id = imagecolorallocate($this->data, $color[0], $color[1], $color[2]); - - if(!$max_width) { - $max_width = $this->width(); - } - - $font = $f3->get("ROOT") . "/fonts/" . $font; - if(!is_file($font)) { - $f3->error(500, "Font file {$font} not found"); - return false; - } - - $words = explode(" ", $text); - $wnum = count($words); - $text = ""; - foreach($words as $w) { - $line_width = 0; - $bbox = imagettfbbox($size, 0, $font, $line); - $word_width = $bbox[2] - $bbox[0]; - if($line_width < $max_width) { - $text .= $w . " "; - } else { - $text .= PHP_EOL . $w . " "; - } - } - - $this->last_data = imagettftext($this->data, $size, 0, $x, $y, $color_id, $font, $text); - return $this->save(); - } - /** * Fill image with a solid color * @param hex $color diff --git a/app/helper/notification.php b/app/helper/notification.php index 4eef2198..deddb30d 100644 --- a/app/helper/notification.php +++ b/app/helper/notification.php @@ -4,6 +4,27 @@ class Notification extends \Prefab { + /** + * Send an email with the UTF-8 character set + * @param string $to + * @param string $subject + * @param string $body + * @return bool + */ + protected function _utf8mail($to, $subject, $body) { + $f3 = \Base::instance(); + + // Set content-type with UTF charset + $headers = 'MIME-Version: 1.0' . "\r\n"; + $headers .= 'Content-type: text/html; charset=utf-8' . "\r\n"; + + // Add sender and recipient information + $headers .= 'To: '. $to . "\r\n"; + $headers .= 'From: '. $f3->get("mail.from") . "\r\n"; + + return mail($to, $subject, $body, $headers); + } + /** * Send an email to watchers with the comment body * @param int $issue_id @@ -20,6 +41,13 @@ public function issue_comment($issue_id, $comment_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)); @@ -27,12 +55,13 @@ public function issue_comment($issue_id, $comment_id) { // Render message body $f3->set("issue", $issue); $f3->set("comment", $comment); - $body = \Template::instance()->render("notification/comment.html"); + $body = $this->_render("notification/comment.html"); + + $subject = "[#{$issue->id}] - New comment on {$issue->name}"; - $subject = "[#" . $issue->id . "] - ".$comment->user_name . " commented on " . $issue->name; // Send to recipients foreach($recipients as $recipient) { - utf8mail($recipient, $subject, $body); + $this->_utf8mail($recipient, $subject, $body); $log->write("Sent comment notification to: " . $recipient); } } @@ -55,6 +84,13 @@ public function issue_update($issue_id, $update_id) { $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; @@ -70,12 +106,20 @@ public function issue_update($issue_id, $update_id) { // Render message body $f3->set("issue", $issue); $f3->set("update", $update); - $body = \Template::instance()->render("notification/update.html"); + $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"; + } + + - $subject = "[#" . $issue->id . "] - ".$update->user_name . " updated " . $issue->name; // Send to recipients foreach($recipients as $recipient) { - utf8mail($recipient, $subject, $body); + $this->_utf8mail($recipient, $subject, $body); $log->write("Sent update notification to: " . $recipient); } } @@ -95,20 +139,27 @@ public function issue_create($issue_id) { $issue = new \Model\Issue\Detail(); $issue->load($issue_id); $f3->set("issue", $issue); - // Get recipient list and DON'T remove current user + + // 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); - //$recipients = array_diff($recipients, array($issue->author_email)); // Render message body $f3->set("issue", $issue); - $body = \Template::instance()->render("notification/new.html"); + $body = $this->_render("notification/new.html"); + + $subject = "[#{$issue->id}] - {$issue->name} created by {$issue->author_name}"; - // Send to recipients - $subject = "[#" . $issue->id . "] - ".$issue->author_name . " created " . $issue->name; // Send to recipients foreach($recipients as $recipient) { - utf8mail($recipient, $subject, $body); + $this->_utf8mail($recipient, $subject, $body); $log->write("Sent create notification to: " . $recipient); } } @@ -130,6 +181,13 @@ public function issue_file($issue_id, $file_id) { $file = new \Model\Issue\File\Detail; $file->load($file_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($file->user_email)); @@ -137,12 +195,13 @@ public function issue_file($issue_id, $file_id) { // Render message body $f3->set("issue", $issue); $f3->set("file", $file); - $body = \Template::instance()->render("notification/file.html"); + $body = $this->_render("notification/file.html"); + + $subject = "[#{$issue->id}] - {$file->user_name} attached a file to {$issue->name}"; - $subject = "[#" . $issue->id . "] - ".$file->user_name . " attached a file to " . $issue->name; // Send to recipients foreach($recipients as $recipient) { - utf8mail($recipient, $subject, $body); + $this->_utf8mail($recipient, $subject, $body); $log->write("Sent file notification to: " . $recipient); } } @@ -164,23 +223,38 @@ public function user_reset($user_id) { // Render message body $f3->set("user", $user); - $body = \Template::instance()->render("notification/user_reset.html"); + $body = $this->_render("notification/user_reset.html"); // Send email to user - $subject = "Reset your password"; - utf8mail($user->email, $subject, $body); + $subject = "Reset your password - " . $f3->get("site.name"); + $this->_utf8mail($user->email, $subject, $body); } } + /** + * 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"); + $body = $this->_render("notification/user_due_issues.html"); + return $this->_utf8mail($user->email, $subject, $body); + } + 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) { - $f3 = \Base::instance(); - $log = new \Log("mail.log"); - $db = $f3->get("db.instance"); + $db = \Base::instance()->get("db.instance"); $recipients = array(); // Add issue author and owner @@ -216,4 +290,16 @@ protected function _issue_watchers($issue_id) { 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 new file mode 100644 index 00000000..f3848f1a --- /dev/null +++ b/app/helper/plugin.php @@ -0,0 +1,81 @@ +_hooks[$hook])) { + $this->_hooks[$hook][] = $callable; + } else { + $this->_hooks[$hook] = array($callable); + } + } + + /** + * Add a navigation item + * @param string $href + * @param string $title + * @param string $match + */ + public function addNavItem($href, $title, $match = null) { + $this->_nav[] = array( + "href" => $href, + "title" => $title, + "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) { + $_nav[] = array( + "file" => $file, + "match" => $match + ); + } + + /** + * Get navbar items, optionally setting matching items as active + * @param string $path + * @return array + */ + public function getNav($path = null) { + $return = $this->_nav; + foreach($return as &$item) { + if($item["match"] && $path && preg_match($item["match"], $path)) { + $item["active"] = true; + } else { + $item["active"] = false; + } + } + return $return; + } + +} diff --git a/app/helper/textile/parser.php b/app/helper/textile/parser.php index 845ce9fc..a5153af2 100644 --- a/app/helper/textile/parser.php +++ b/app/helper/textile/parser.php @@ -1748,12 +1748,12 @@ protected function prepGlyphs() $this->glyph_replace[] = '$1'.$this->symbols['dimension'].'$2'; // Apostrophe - $this->glyph_search[] = '/('.$this->regex_snippets['wrd'].'|\))\''. + /*$this->glyph_search[] = '/('.$this->regex_snippets['wrd'].'|\))\''. '('.$this->regex_snippets['wrd'].')/'.$this->regex_snippets['mod']; - $this->glyph_replace[] = '$1'.$this->symbols['apostrophe'].'$2'; + $this->glyph_replace[] = '$1'.$this->symbols['apostrophe'].'$2';*/ // Back in '88/the '90s but not in his '90s', '1', '1.' '10m' or '5.png' - $this->glyph_search[] = '/('.$this->regex_snippets['space'].')\''. + /*$this->glyph_search[] = '/('.$this->regex_snippets['space'].')\''. '(\d+'.$this->regex_snippets['wrd'].'?)\b(?![.]?['.$this->regex_snippets['wrd'].']*?\')/'. $this->regex_snippets['mod']; $this->glyph_replace[] = '$1'.$this->symbols['apostrophe'].'$2'; @@ -1799,7 +1799,7 @@ protected function prepGlyphs() '(['.$this->regex_snippets['abr'].']{3,})'. '(['.$this->regex_snippets['nab'].']*)(?='.$this->regex_snippets['space'].'|'.$pnc.'|<|$)'. '(?=[^">]*?(<|$))/'.$this->regex_snippets['mod']; - $this->glyph_replace[] = '$1'.$this->uid.':glyph:$2$3'; + $this->glyph_replace[] = '$1'.$this->uid.':glyph:$2$3';*/ // Ellipsis $this->glyph_search[] = '/([^.]?)\.{3}/'; diff --git a/app/helper/update.php b/app/helper/update.php index 87b37292..830363bc 100644 --- a/app/helper/update.php +++ b/app/helper/update.php @@ -89,6 +89,15 @@ function humanReadableValues($field, $old_val, $new_val) { $new_val = $type->name; } break; + case "hours_total": + $name = "Planned Hours"; + break; + case "hours_remaining": + $name = "Remaining Hours"; + break; + case "hours_spent": + $name = "Spent Hours"; + break; } // Generate human readable field name if not already specified diff --git a/app/helper/view.php b/app/helper/view.php new file mode 100644 index 00000000..02547cb8 --- /dev/null +++ b/app/helper/view.php @@ -0,0 +1,174 @@ +get("$hash.tex")) !== false) { + return $val; + } + } + + // Value wasn't cached, run the parser + $tex = new Textile\Parser(); + $tex->setDocumentType('html5') + ->setDimensionlessImages(true); + $val = $tex->parse($str); + + // Find issue IDs and convert to links + $val = preg_replace("/(?<=[\s,\(^])#([0-9]+)(?=[\s,\)\.,$])/", "#$1", $val); + + // Convert URLs to links + $val = $this->make_clickable($val); + + // Cache the value if $ttl was given + if($ttl !== false) { + $cache->set("$hash.tex", $val, $ttl); + } + + // Return the parsed value + return $val; + } + + + /** + * Internal function used by make_clickable + * @param array $matches + * @return string + */ + protected function _make_url_clickable_cb($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; + } + + /** + * Internal function used by make_clickable + * @param array $m + * @return string + */ + protected function _make_web_ftp_clickable_cb($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; + } + + /** + * Internal function used by make_clickable + * @param array $m + * @return string + */ + protected function _make_email_clickable_cb($m) { + $email = $m[2].'@'.$m[3]; + return $m[1]."$email"; + } + + /** + * Converts recognized URLs and email addresses into HTML hyperlinks + * @param string $s + * @return string + */ + public function make_clickable($s) { + $s = ' '.$s; + // in testing, using arrays here was found to be faster + $s = preg_replace_callback('#([\s>])([\w]+?://[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is', array($this, '_make_url_clickable_cb'), $s); + $s = preg_replace_callback('#([\s>])((www|ftp)\.[\w\\x80-\\xff\#!$%&~/.\-;:=,?@\[\]+]*)#is', array($this, '_make_web_ftp_clickable_cb'), $s); + $s = preg_replace_callback('#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})#i', array($this, '_make_email_clickable_cb'), $s); + + // this one is not in an array because we need it to run last, for cleanup of accidental links within links + $s = preg_replace("#(]+?>|>))]+?>([^>]+?)#i", "$1$3",$s); + $s = trim($s); + return $s; + } + + + /** + * 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) { + $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; + } + +} diff --git a/app/model/base.php b/app/model.php similarity index 68% rename from app/model/base.php rename to app/model.php index aba8c9b7..3ca5edf6 100644 --- a/app/model/base.php +++ b/app/model.php @@ -1,8 +1,6 @@ fields) && !$this->query && !$this->get("created_date")) { - $this->set("created_date", now()); + $this->set("created_date", date("Y-m-d H:i:s")); } return parent::save(); } @@ -38,7 +37,7 @@ function save() { */ function delete() { if(array_key_exists("deleted_date", $this->fields)) { - $this->deleted_date = now(); + $this->deleted_date = date("Y-m-d H:i:s"); return $this->save(); } else { return $this->erase(); @@ -60,12 +59,36 @@ function load($filter=NULL, array $options=NULL, $ttl=0) { } } + /** + * 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 get_prev($key) { + protected function _getPrev($key) { if(!$this->query) { return null; } diff --git a/app/model/attribute.php b/app/model/attribute.php index 3f65b81c..87c81cac 100644 --- a/app/model/attribute.php +++ b/app/model/attribute.php @@ -2,7 +2,7 @@ namespace Model; -class Attribute extends Base { +class Attribute extends \Model { protected $_table_name = "attribute"; diff --git a/app/model/attribute/issue_type.php b/app/model/attribute/issue_type.php index 1ca117ff..c4c67c54 100644 --- a/app/model/attribute/issue_type.php +++ b/app/model/attribute/issue_type.php @@ -2,7 +2,7 @@ namespace Model\Attribute; -class Attribute extends \Model\Base { +class Attribute extends \Model { protected $_table_name = "attribute_issue_type"; diff --git a/app/model/category.php b/app/model/category.php deleted file mode 100644 index 6de527cc..00000000 --- a/app/model/category.php +++ /dev/null @@ -1,10 +0,0 @@ -set("deleted_date", now()); + $this->set("deleted_date", date("Y-m-d H:i:s")); return $this->save(false); } /** - * Log issue update, send notifications + * Log and save an issue update * @param boolean $notify - * @return Issue + * @return Issue\Update */ - public function save($notify = true) { + protected function _saveUpdate($notify = true) { $f3 = \Base::instance(); - // Check if updating or inserting - if($this->query) { + // Ensure issue is not tied to itself as a parent + if($this->get("id") == $this->get("parent_id")) { + $this->set("parent_id", $this->_getPrev("parent_id")); + } - // Ensure issue is not tied to itself as a parent - if($this->get("id") == $this->get("parent_id")) { - $this->set("parent_id", $this->get_prev("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($this->exists('update_comment')) { + $update->comment_id = $this->get('update_comment'); + } + $update->save(); + + // Set hours_total to the hours_remaining value if it's 0 or null + if($this->get("hours_remaining") && !$this->get("hours_total")) { + $this->set("hours_total", $this->get("hours_remaining")); + } + + // Set hours remaining to 0 if the issue has been closed + if($this->get("closed_date") && $this->get("hours_remaining")) { + $this->set("hours_remaining", 0); + } + + // Create a new task if repeating + if($this->get("closed_date") && $this->get("repeat_cycle") != "none") { + + $repeat_issue = new \Model\Issue(); + $repeat_issue->name = $this->get("name"); + $repeat_issue->type_id = $this->get("type_id"); + $repeat_issue->sprint_id = $this->get("sprint_id"); + $repeat_issue->author_id = $this->get("author_id"); + $repeat_issue->owner_id = $this->get("owner_id"); + $repeat_issue->description = $this->get("description"); + $repeat_issue->repeat_cycle = $this->get("repeat_cycle"); + $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->due_date = date("Y-m-d", strtotime("tomorrow")); + break; + case 'weekly': + $repeat_issue->due_date = date("Y-m-d", strtotime($this->get("due_date") . " +1 week")); + break; + case 'monthly': + $repeat_issue->due_date = date("Y-m-d", strtotime($this->get("due_date") . " +1 month")); + break; + case 'sprint': + $sprint = new \Model\Sprint(); + $sprint->load(array("start_date > NOW()"), array('order'=>'start_date')); + $repeat_issue->due_date = $sprint->end_date; + break; + default: + $repeat_issue->repeat_cycle = 'none'; } - // Log update - $update = new \Model\Issue\Update(); - $update->issue_id = $this->id; - $update->user_id = $f3->get("user.id"); - $update->created_date = now(); - if($this->exists('update_comment')) { - $update->comment_id = $this->get('update_comment'); + // If the project was in a sprint before, put it in a sprint again. + if($this->get("sprint_id")) { + $sprint = new \Model\Sprint(); + $sprint->load(array("id > ? AND end_date > ? AND start_date < ?", $this->get("sprint_id"), $repeat_issue->due_date, $repeat_issue->due_date), array('order'=>'start_date')); + $repeat_issue->sprint_id = $sprint->id; } - $update->save(); - $updated = 0; + $repeat_issue->save(); + $notification = \Helper\Notification::instance(); + $notification->issue_create($repeat_issue->id); + $this->set("repeat_cycle", "none"); + } - // Set hours_total to the hours_remaining value if it's 0 or null - if($this->get("hours_remaining") && !$this->get("hours_total")) { - $this->set("hours_total", $this->get("hours_remaining")); + // Move all non-project children to same sprint + $this->resetChildren(); + + // Log updated fields + $updated = 0; + 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 ++; } + } - // Set hours remaining to 0 if the issue has been closed - if($this->get("closed_date") && $this->get("hours_remaining")) { - $this->set("hours_remaining", 0); - } + // Delete update if no fields were changed + if(!$updated) { + $update->delete(); + } - // Create a new task if repeating - if($this->get("closed_date") && $this->get("repeat_cycle") != "none") { - - $repeat_issue = new \Model\Issue(); - $repeat_issue->name = $this->get("name"); - $repeat_issue->type_id = $this->get("type_id"); - $repeat_issue->sprint_id = $this->get("sprint_id"); - $repeat_issue->author_id = $this->get("author_id"); - $repeat_issue->owner_id = $this->get("owner_id"); - $repeat_issue->description = $this->get("description"); - $repeat_issue->repeat_cycle = $this->get("repeat_cycle"); - $repeat_issue->created_date = now(); - - // Find a due date in the future - switch($repeat_issue->repeat_cycle) { - case 'daily': - $repeat_issue->due_date = date("Y-m-d", strtotime("tomorrow")); - break; - case 'weekly': - $dow = date("l", strtotime($this->get("due_date"))); - $repeat_issue->due_date = date("Y-m-d", strtotime($this->get("due_date") . " +1 week" )); - break; - case 'monthly': - $day = date("d", strtotime($this->get("due_date"))); - $month = date("m"); - $year = date("Y"); - $repeat_issue->due_date = date("Y-m-d", mktime(0, 0, 0, $month + 1, $day, $year)); - break; - case 'sprint': - $sprint = new \Model\Sprint(); - $sprint->load(array("start_date > NOW()"), array('order'=>'start_date')); - $repeat_issue->due_date = $sprint->end_date; - break; - default: - $repeat_issue->repeat_cycle == 'none'; - } + // Send back the update + return $update->id ? $update : false; - // If the project was in a sprint before, put it in a sprint again. - if($this->get("sprint_id")) { - $sprint = new \Model\Sprint(); - $sprint->load(array("id > ? AND end_date > ? AND start_date < ?", $this->get("sprint_id"), $repeat_issue->due_date, $repeat_issue->due_date), array('order'=>'start_date')); - $repeat_issue->sprint_id = $sprint->id; - } + } - $repeat_issue->save(); - $notification = \Helper\Notification::instance(); - $notification->issue_create($repeat_issue->id); - $this->set("repeat_cycle", "none"); - } + /** + * Log issue update, send notifications + * @param boolean $notify + * @return Issue + */ + public function save($notify = true) { + $f3 = \Base::instance(); - // Move all non-project children to same sprint - $this->resetChildren(); - - // Log updated fields - foreach ($this->fields as $key=>$field) { - if ($field["changed"] && $field["value"] != $this->get_prev($key)) { - $update_field = new \Model\Issue\Update\Field(); - $update_field->issue_update_id = $update->id; - $update_field->field = $key; - $update_field->old_value = $this->get_prev($key); - $update_field->new_value = $field["value"]; - $update_field->save(); - $updated ++; - } + // 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->get("description"))) { + $this->set("description", preg_replace("/([0-9]{3,4}-){3}([0-9]{3,4})/", "************$2", $this->get("description"))); } + } - // Save issue and send notifications + // Make due dates correct + if($this->due_date) { + $this->due_date = date("Y-m-d", strtotime($this->due_date)); + } + + // Check if updating or inserting + if($this->query) { + + // Save issue updates and send notifications + $update = $this->_saveUpdate(); $issue = parent::save(); - if($updated) { - if($notify) { - $notification = \Helper\Notification::instance(); - $notification->issue_update($this->get("id"), $update->id); - } - } else { - $update->delete(); + if($update->id && $notify) { + $notification = \Helper\Notification::instance(); + $notification->issue_update($this->get("id"), $update->id); } } else { @@ -196,25 +218,6 @@ public function save($notify = true) { return empty($issue) ? parent::save() : $issue; } - /** - * Preload custom attributes - * @param string|array $filter - * @param array $options - * @param integer $ttl - * @return array|FALSE - */ - function load($filter=NULL, array $options=NULL, $ttl=0) { - // Load issue from - $return = parent::load($filter, $options, $ttl); - - if($this->get("id")) { - $attr = new \Model\Custom("attribute_value_detail"); - $attrs = $attr->find(array("issue_id = ?", $this->get("id"))); - } - - return $return; - } - /** * Duplicate issue and all sub-issues * @return Issue @@ -228,9 +231,11 @@ function duplicate() { $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->save(); // Run the recursive function to duplicate the complete descendant tree @@ -255,10 +260,12 @@ protected function _duplicateTree($id, $new_id) { // 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->set("parent_id", $new_id); $new_child->save(); @@ -274,12 +281,16 @@ protected function _duplicateTree($id, $new_id) { * Move all non-project children to same sprint * @return Issue */ - public function resetChildren() { + public function resetChildren($replace_existing = true) { $f3 = \Base::instance(); if($this->get("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"; + } $db = $f3->get("db.instance"); $db->exec( - "UPDATE issue SET sprint_id = :sprint WHERE parent_id = :issue AND type_id != :type", + $query, array( "sprint" => $this->get("sprint_id"), "issue" => $this->get("id"), diff --git a/app/model/issue/comment.php b/app/model/issue/comment.php index 7c034e5a..ec5302e4 100644 --- a/app/model/issue/comment.php +++ b/app/model/issue/comment.php @@ -2,9 +2,26 @@ namespace Model\Issue; -class Comment extends \Model\Base { +class Comment extends \Model { protected $_table_name = "issue_comment"; + /** + * Save the comment + * @return Comment + */ + public function save() { + $f3 = \Base::instance(); + + // Censor credit card numbers if enabled + if($f3->get("security.block_ccs")) { + if(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/file.php b/app/model/issue/file.php index 8886f05a..c2e70f73 100644 --- a/app/model/issue/file.php +++ b/app/model/issue/file.php @@ -2,7 +2,7 @@ namespace Model\Issue; -class File extends \Model\Base { +class File extends \Model { protected $_table_name = "issue_file"; diff --git a/app/model/issue/priority.php b/app/model/issue/priority.php index 9171850c..ad29cd21 100644 --- a/app/model/issue/priority.php +++ b/app/model/issue/priority.php @@ -2,7 +2,7 @@ namespace Model\Issue; -class Priority extends \Model\Base { +class Priority extends \Model { protected $_table_name = "issue_priority"; diff --git a/app/model/issue/status.php b/app/model/issue/status.php index dc307569..b6279059 100644 --- a/app/model/issue/status.php +++ b/app/model/issue/status.php @@ -2,7 +2,7 @@ namespace Model\Issue; -class Status extends \Model\Base { +class Status extends \Model { protected $_table_name = "issue_status"; diff --git a/app/model/issue/type.php b/app/model/issue/type.php index 300151d2..dbb9b259 100644 --- a/app/model/issue/type.php +++ b/app/model/issue/type.php @@ -2,7 +2,7 @@ namespace Model\Issue; -class Type extends \Model\Base { +class Type extends \Model { protected $_table_name = "issue_type"; diff --git a/app/model/issue/update.php b/app/model/issue/update.php index 606cb5d3..bce44d29 100644 --- a/app/model/issue/update.php +++ b/app/model/issue/update.php @@ -2,7 +2,7 @@ namespace Model\Issue; -class Update extends \Model\Base { +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 3f218262..6d74ba09 100644 --- a/app/model/issue/update/field.php +++ b/app/model/issue/update/field.php @@ -2,7 +2,7 @@ namespace Model\Issue\Update; -class Field extends \Model\Base { +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 b0314409..cddabfb4 100644 --- a/app/model/issue/watcher.php +++ b/app/model/issue/watcher.php @@ -2,9 +2,19 @@ namespace Model\Issue; -class Watcher extends \Model\Base { +class Watcher extends \Model { protected $_table_name = "issue_watcher"; + public function findby_watcher ($f3, $user_id = 0, $orderby = 'id') { + $db = $f3->get("db.instance"); + return $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 '. + 'ORDER BY :orderby ', + array(':user_id' => $user_id, ':orderby' => $orderby) + ); + } + } diff --git a/app/model/sprint.php b/app/model/sprint.php index 9cfbe185..91e42645 100644 --- a/app/model/sprint.php +++ b/app/model/sprint.php @@ -2,7 +2,7 @@ namespace Model; -class Sprint extends Base { +class Sprint extends \Model { protected $_table_name = "sprint"; diff --git a/app/model/user.php b/app/model/user.php index e8428534..45f7c13a 100644 --- a/app/model/user.php +++ b/app/model/user.php @@ -2,7 +2,7 @@ namespace Model; -class User extends Base { +class User extends \Model { protected $_table_name = "user"; @@ -28,14 +28,13 @@ public function loadCurrent() { * @return string|bool */ public function avatar($size = 80) { - $f3 = \Base::instance(); if(!$this->get("id")) { return false; } - if($this->get("avatar_filename") && is_file($f3->get("ROOT") . "/uploads/avatars/" . $this->get("avatar_filename"))) { - return "/avatar/$size/" . $this->get("id") . ".png"; + if($this->get("avatar_filename") && is_file("uploads/avatars/" . $this->get("avatar_filename"))) { + return "/avatar/$size-" . $this->get("id") . ".png"; } - return gravatar($this->get("email"), $size); + return \Helper\View::instance()->gravatar($this->get("email"), $size); } /** @@ -43,7 +42,7 @@ public function avatar($size = 80) { * @return array */ public function getAll() { - return $this->find("deleted_date IS NULL AND role IN ('user', 'admin')", array("order" => "name ASC")); + return $this->find("deleted_date IS NULL AND role != 'group'", array("order" => "name ASC")); } /** @@ -54,5 +53,107 @@ public function getAllGroups() { return $this->find("deleted_date IS NULL AND role = 'group'", array("order" => "name ASC")); } + /** + * Send an email alert with issues due on the given date + * @param string $date + * @return bool + */ + public function sendDueAlert($date = '') { + if(!$this->get("id")) { + return false; + } + + if(!$date) { + $date = date("Y-m-d", \Helper\View::instance()->utc2local()); + } + + $issue = new \Model\Issue; + $issues = $issue->find(array("due_date = ? AND owner_id = ? AND closed_date IS NULL AND deleted_date IS NULL", $date, $this->get("id")), 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) { + $db = \Base::instance()->get("db.instance"); + + \Helper\View::instance()->utc2local(); + $offset = \Base::instance()->get("site.timeoffset"); + + if(!$time) { + $time = strtotime("-2 weeks", time() + $offset); + } + + $result = array(); + $result["spent"] = $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->get("id"), ":offset" => $offset, ":offset2" => $offset, ":date" => date("Y-m-d H:i:s", $time)) + ); + $result["closed"] = $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->get("id"), ":offset" => $offset, ":offset2" => $offset, ":date" => date("Y-m-d H:i:s", $time)) + ); + $result["created"] = $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->get("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("M 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; + } + } diff --git a/app/model/user/group.php b/app/model/user/group.php index 832e96c3..f986702b 100644 --- a/app/model/user/group.php +++ b/app/model/user/group.php @@ -2,7 +2,7 @@ namespace Model\User; -class Group extends \Model\Base { +class Group extends \Model { protected $_table_name = "user_group"; diff --git a/app/model/wiki/page.php b/app/model/wiki/page.php deleted file mode 100644 index 3a4a4a56..00000000 --- a/app/model/wiki/page.php +++ /dev/null @@ -1,10 +0,0 @@ -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 + * @return Plugin + */ + final protected function _addNav($href, $title, $match = null) { + \Helper\Plugin::instance()->addNavItem($href, $title, $match); + 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"); + } + +} diff --git a/app/plugin/README.md b/app/plugin/README.md index 73ae6a4c..fa40f7b6 100644 --- a/app/plugin/README.md +++ b/app/plugin/README.md @@ -1,15 +1,12 @@ Phproject plugins should be placed in this directory. -A plugin can either be a single PHP file containing a single class, or a directory containing a `base.php` file, which includes the main class. - -Plugins must have a _load() method in the main class, which returns the hooks the plugin will use, as well as it's metadata. - -Plugins should have a comment block at the start of the file containing at least the plugin name and developer's name, in a standard, easily parsed format. - -Plugins may have an _installed() method which returns a boolean value specifying whether the plugin has been installed correctly. This should include checks such as required database tables and server configuration options, and should cache a `TRUE` result using Fat Free's caching abstraction layer to improve performance on subsequent requests after installation has succeeded. - -Plugins may have a routes.ini file if they are in a directory, which will be automatically loaded if the plugin's _installed() method returns `TRUE`, which must route plugin-specific paths to the correct actions within the plugin's main class or sub-classes. - -Plugins may have a database.sql file if they are in a directory, which will be automatically loaded if the plugin's _installed() method returns `FALSE`, which must include the SQL queries to add the required database tables and rows for the plugin to operate. - -Plugins may have a _install() method, which will be called if _installed() returns false. This method must +* Plugins must have a `Base` class in `base.php` that extends `\Plugin` + * This class should contain all core code for the plugin, including all methods and properties in the Plugin Standards +* Plugins must have a `_load()` method which is called when initializing the plugin + * Any hooks and routes used in the plugin should be initialized in this method +* Plugins must have a PHPDoc comment block at the start of the `base.php` file + * Block must contain at least the @package tag + * Block should contain the @author tag +* Plugins must have an `_installed()` method which will be called to check the installation status of the plugin +* Plugins may have an `_install()` method which will be called if `_installed()` returns `false` +* Plugins should follow the [Code Standards](http://localhost:4000/contribute.html) diff --git a/app/routes.ini b/app/routes.ini index 0df0c469..d950574f 100644 --- a/app/routes.ini +++ b/app/routes.ini @@ -6,7 +6,9 @@ GET /login = Controller\Index->login GET|POST /reset = Controller\Index->reset GET|POST /reset/@hash = 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 # Issues GET /issues = Controller\Issues->index @@ -15,6 +17,9 @@ GET /issues/new/@type = Controller\Issues->add GET /issues/new/@type/@parent = Controller\Issues->add 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 /issues/export/@id = Controller\Issues->export_single 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 @@ -36,6 +41,9 @@ POST /user/avatar = Controller\User->avatar GET /user/dashboard = Controller\User->dashboard GET /user/@username = Controller\User->single +# Feeds +GET /atom.xml = Controller\Index->atom + # Administration GET|POST /admin = Controller\Admin->index GET /admin/@tab = Controller\Admin->@tab @@ -74,9 +82,10 @@ GET /backlog/@filter = Controller\Backlog->index GET /backlog/@filter/@groupid = Controller\Backlog->index # Files -GET /files/thumb/@size/@id.@format = Controller\Files->thumb +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 +GET /avatar/@size-@id.@format = Controller\Files->avatar # Wiki GET /wiki = Controller\Wiki->index @@ -90,9 +99,6 @@ POST /issues.json = Controller\Api\Issues->post GET /issues/@id.json = Controller\Api\Issues->single_get PUT /issues/@id.json = Controller\Api\Issues->single_put DELETE /issues/@id.json = Controller\Api\Issues->single_delete - -#User API -#Not sure what @ID will not work. So for now using @username GET /user/@username.json = Controller\Api\User->single_get GET /useremail/@email.json = Controller\Api\User->single_email GET /user.json = Controller\Api\User->get diff --git a/app/view/admin/attributes.html b/app/view/admin/attributes.html index 034761a1..8fd7fbba 100644 --- a/app/view/admin/attributes.html +++ b/app/view/admin/attributes.html @@ -11,10 +11,10 @@
 New @@ -24,7 +24,7 @@ ID - Name + {{ @dict.name }} Type diff --git a/app/view/admin/attributes/edit.html b/app/view/admin/attributes/edit.html index ac938969..b6501c55 100644 --- a/app/view/admin/attributes/edit.html +++ b/app/view/admin/attributes/edit.html @@ -10,15 +10,15 @@
{{ empty(@attribute) ? 'New' : 'Edit' }} Attribute
- +
@@ -54,8 +54,8 @@
- - Cancel + + {{ @dict.cancel }}
diff --git a/app/view/admin/groups.html b/app/view/admin/groups.html index 48a5344a..8afbd289 100644 --- a/app/view/admin/groups.html +++ b/app/view/admin/groups.html @@ -11,14 +11,14 @@ @@ -26,15 +26,15 @@ ID - Name + {{ @dict.name }} Members - Task Color + {{ @dict.task_color }} - + {{ @group.id }} {{ @group.name }} {{ @group.count }} @@ -62,7 +62,7 @@
diff --git a/app/view/admin/groups/edit.html b/app/view/admin/groups/edit.html index bdeb4bb3..a6eec598 100644 --- a/app/view/admin/groups/edit.html +++ b/app/view/admin/groups/edit.html @@ -10,10 +10,10 @@
diff --git a/app/view/admin/index.html b/app/view/admin/index.html index b09e3824..205d3455 100644 --- a/app/view/admin/index.html +++ b/app/view/admin/index.html @@ -10,28 +10,28 @@
-

Users

+

{{ @dict.users }}

{{ @count_user }}

-

Issues

+

{{ @dict.issues }}

{{ @count_issue }}

-

Comments

+

{{ @dict.comments }}

{{ @count_issue_comment }}

@@ -47,7 +47,7 @@

{{ @count_issue_update }}

{{ @site.name }}

{{ @site.description }}
- Theme: {{ @site.theme }}
+ {{ @dict.theme }}: {{ @site.theme }}
Logo: {{ @site.logo }}
@@ -59,8 +59,8 @@
{{ @site.description }}
Host: {{ @db.host }}:{{ @db.port }}
Schema: {{ @db.name }}
- Username: {{ @db.user }}
- Password: {{ str_repeat("*", strlen(@db.pass)) }}
+ {{ @dict.username }}: {{ @db.user }}
+ {{ @dict.password }}: {{ str_repeat("*", strlen(@db.pass)) }}
@@ -114,8 +114,8 @@

SMTP (Outgoing mail)

From: {{ @mail.from }}

IMAP (Incoming mail)

Hostname: {{ @imap.hostname }}
- Username: {{ @imap.username }}
- Password: {{ str_repeat("*", strlen(@imap.password)) }}
+ {{ @dict.username }}: {{ @imap.username }}
+ {{ @dict.password }}: {{ str_repeat("*", strlen(@imap.password)) }}

Gravatar

diff --git a/app/view/admin/sprints.html b/app/view/admin/sprints.html index cc5dd06f..4d152258 100644 --- a/app/view/admin/sprints.html +++ b/app/view/admin/sprints.html @@ -1,4 +1,4 @@ - + @@ -11,20 +11,20 @@ - +
- + diff --git a/app/view/admin/sprints/edit.html b/app/view/admin/sprints/edit.html index c3e5024d..fa49364f 100644 --- a/app/view/admin/sprints/edit.html +++ b/app/view/admin/sprints/edit.html @@ -10,15 +10,15 @@
Edit Sprint
- +
@@ -37,8 +37,8 @@
- - Cancel + + {{ @dict.cancel }}
@@ -49,7 +49,7 @@ +
diff --git a/app/view/backlog/old.html b/app/view/backlog/old.html index 40bbe314..c3e1a7a5 100644 --- a/app/view/backlog/old.html +++ b/app/view/backlog/old.html @@ -16,13 +16,13 @@

- Backlog + Backlog
-

+

Drag projects here to remove from a sprint

@@ -32,11 +32,9 @@ - + diff --git a/app/view/blocks/footer.html b/app/view/blocks/footer.html index c14bb3ed..c09597d3 100644 --- a/app/view/blocks/footer.html +++ b/app/view/blocks/footer.html @@ -16,28 +16,174 @@

- + - {~ foreach(@queries_log as @r) if(@r.cached) ++@cached; ~} - {{ count(@queries_log) - @cached }} queries · + {~ foreach(@querylog as @r) if(@r.cached) ++@cached; ~} + {{ @dict.n_queries,(count(@querylog) - @cached) | format }} · - {{ round(@pagemtime * 1000, 0) }}ms · + {{ round(@pagemtime * 1000, 0) }}ms · - {{ format_filesize(memory_get_peak_usage()) }} · + {{ \Helper\View::instance()->formatFilesize(memory_get_peak_usage()) }} - {{ substr(@revision, 0, 7) }} · + · {{ substr(@revision, 0, 7) }} - © {{ date("Y") }}

+ + + + + + + + + diff --git a/app/view/blocks/issue-list.html b/app/view/blocks/issue-list.html index 95241316..6475b2e3 100644 --- a/app/view/blocks/issue-list.html +++ b/app/view/blocks/issue-list.html @@ -1,11 +1,17 @@ -
- - + +
IDName{{ @dict.name }} Start Date End Date
+ + - + + + + + + + + + {{ !empty(@dict.cols[@heading]) ? @dict.cols[@heading] : ucwords(str_replace(array('_', 'id'), array(' ', 'ID'), @heading)) }} + - @@ -167,6 +174,121 @@ +
+ + +  {{ @dict.export }} + + @@ -35,17 +41,17 @@ - + @@ -67,7 +73,7 @@ - + @@ -81,7 +87,7 @@ - +
- - - - - - - - - {{ ucwords(str_replace("_"," ", @heading)) }}
+ +{{ @dict.bulk_actions }} + + diff --git a/app/view/blocks/navbar-public.html b/app/view/blocks/navbar-public.html index 93e480ab..0ea60dab 100644 --- a/app/view/blocks/navbar-public.html +++ b/app/view/blocks/navbar-public.html @@ -1,7 +1,7 @@