diff --git a/_codebase_output.txt b/_codebase_output.txt new file mode 100644 index 0000000..bb3fec6 --- /dev/null +++ b/_codebase_output.txt @@ -0,0 +1,9995 @@ +--- Folder Structure --- +tp_servicedesk/ + LICENSE + README.md + _codebase_output.txt + _codebase_schemafile.sql + composer.json + package.json + app/ + config/ + routes.ini + controllers/ + AttachmentController.php + AuthController.php + BaseController.php + CommentController.php + DashboardController.php + HomeController.php + KBController.php + ParsedownPreview.php + ProjectController.php + TagController.php + ThemeController.php + TicketController.php + UserController.php + Admin/ + HomeController.php + TicketOptionsController.php + UserController.php + extensions/ + BulmaFormHelper.php + IconsHelper.php + ParsedownHelper.php + ParsedownTableExtension.php + interfaces/ + CRUD.php + models/ + Attachment.php + Comment.php + Tag.php + Ticket.php + TicketPriority.php + TicketStatus.php + traits/ + RequiresAuth.php + downloads/ + lib/ + public/ + index.php + logo.svg + style.css + test.md.php + css/ + js/ + kb_edit.js + markdown_preview.js + ticket_view.js + tp_md_editor.js + tmp/ + scss/ + main.scss + components/ + _ticket-item.scss + vendor/ + _bulma-tools.scss + _bulma.scss + storage/ + ui/ + modal/ + partials/ + ticket_item.html + parts/ + clipboard.html + session/ + error.html + templates/ + layout.html + views/ + dashboard.html + home.html + login.html + admin/ + index.html + priorities/ + create.html + index.html + attachment/ + index.html + comments/ + view.html + kb/ + create.html + edit.html + index.html + view.html + project/ + create.html + edit.html + index.html + view.html + tag/ + create.html + index.html + ticket/ + create.html + edit.html + edit.html.v1 + index.html + index_row.html + view.html + user/ + edit.html + index.html + +============================================================ + +--- File Contents --- + + +--- File: LICENSE --- +MIT License + +Copyright (c) 2025 tp_dhu + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- End File: LICENSE --- + + +--- File: README.md --- +# Coding Approach +- Classes + - Class names should be in `CapitalCamelCase` + - Class functions will be in `camelCase` +- Functions + - Function variables will be in `snake_case` +- Arrays + - Array keys will be in `snake_case` +- SQL + - table names, and columns names will be in `snake_case` +- Don't repeat yourself (DRY) +- Each fucntion should have a single purpose + +# tp_servicedesk + +A { service desk, ticket, knowledge base } web application written in PHP using fat free framework. + +Used to keep track of ongoing projects/tasks, allow to view and search historic projects which may have already answered a previous question. Knowledge Base built from applications using markdown. + +## Plesk - quest notes + + - plesk ext composer --application -register -domain desk.tinylink.uk -path desk.tinylink.uk/tp_servicedesk + ~ https://www.plesk.com/kb/support/how-to-change-in-the-php-composer-extension-the-path-of-the-composer-json-file/ + +## Milestones +- Database created locally +- .gitignore added +- added AuthController - login and logout process +--- End File: README.md --- + + +--- File: _codebase_output.txt --- +--- Folder Structure --- +tp_servicedesk/ + LICENSE + README.md + _codebase_output.txt + _codebase_schemafile.sql + composer.json + package.json + app/ + config/ + routes.ini + controllers/ + AttachmentController.php + AuthController.php + BaseController.php + CommentController.php + DashboardController.php + HomeController.php + KBController.php + ParsedownPreview.php + ProjectController.php + TagController.php + ThemeController.php + TicketController.php + UserController.php + Admin/ + HomeController.php + TicketOptionsController.php + UserController.php + extensions/ + BulmaFormHelper.php + IconsHelper.php + ParsedownHelper.php + ParsedownTableExtension.php + interfaces/ + CRUD.php + models/ + Attachment.php + Comment.php + Tag.php + Ticket.php + TicketPriority.php + TicketStatus.php + traits/ + RequiresAuth.php + downloads/ + lib/ + public/ + index.php + logo.svg + style.css + test.md.php + css/ + js/ + kb_edit.js + markdown_preview.js + ticket_view.js + tp_md_editor.js + tmp/ + scss/ + main.scss + components/ + _ticket-item.scss + vendor/ + _bulma-tools.scss + _bulma.scss + storage/ + ui/ + modal/ + partials/ + ticket_item.html + parts/ + clipboard.html + session/ + error.html + templates/ + layout.html + views/ + dashboard.html + home.html + login.html + admin/ + index.html + priorities/ + create.html + index.html + attachment/ + index.html + comments/ + view.html + kb/ + create.html + edit.html + index.html + view.html + project/ + create.html + edit.html + index.html + view.html + tag/ + create.html + index.html + ticket/ + create.html + edit.html + edit.html.v1 + index.html + index_row.html + view.html + user/ + edit.html + index.html + +============================================================ + +--- File Contents --- + + +--- File: LICENSE --- +MIT License + +Copyright (c) 2025 tp_dhu + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- End File: LICENSE --- + + +--- File: README.md --- +# Coding Approach +- Classes + - Class names should be in `CapitalCamelCase` + - Class functions will be in `camelCase` +- Functions + - Function variables will be in `snake_case` +- Arrays + - Array keys will be in `snake_case` +- SQL + - table names, and columns names will be in `snake_case` +- Don't repeat yourself (DRY) +- Each fucntion should have a single purpose + +# tp_servicedesk + +A { service desk, ticket, knowledge base } web application written in PHP using fat free framework. + +Used to keep track of ongoing projects/tasks, allow to view and search historic projects which may have already answered a previous question. Knowledge Base built from applications using markdown. + +## Plesk - quest notes + + - plesk ext composer --application -register -domain desk.tinylink.uk -path desk.tinylink.uk/tp_servicedesk + ~ https://www.plesk.com/kb/support/how-to-change-in-the-php-composer-extension-the-path-of-the-composer-json-file/ + +## Milestones +- Database created locally +- .gitignore added +- added AuthController - login and logout process +--- End File: README.md --- + + +--- File: _codebase_output.txt --- + +--- End File: _codebase_output.txt --- + + +--- File: _codebase_schemafile.sql --- +-- MariaDB dump 10.19 Distrib 10.4.32-MariaDB, for Win64 (AMD64) +-- +-- Host: localhost Database: tp_servicedesk +-- ------------------------------------------------------ +-- Server version 10.4.32-MariaDB + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `attachments` +-- + +DROP TABLE IF EXISTS `attachments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `attachments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `path` text NOT NULL, + `file_name` text NOT NULL, + `ticket_id` int(11) DEFAULT NULL, + `kb_id` int(11) DEFAULT NULL, + `version_number` int(11) NOT NULL, + `uploaded_by` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb` +-- + +DROP TABLE IF EXISTS `kb`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `kb` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` text NOT NULL, + `content` text NOT NULL, + `created_by` int(11) NOT NULL, + `updated_by` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb_tags` +-- + +DROP TABLE IF EXISTS `kb_tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `kb_tags` ( + `kb_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + `id` int(11) NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `project_events` +-- + +DROP TABLE IF EXISTS `project_events`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `project_events` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `event_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `description` text NOT NULL, + `created_by` int(11) NOT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `project_links` +-- + +DROP TABLE IF EXISTS `project_links`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `project_links` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `url` text NOT NULL, + `description` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `project_tasks` +-- + +DROP TABLE IF EXISTS `project_tasks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `project_tasks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `title` varchar(255) NOT NULL, + `description` text NOT NULL, + `status` enum('pending','in_progress','done','') NOT NULL, + `start_date` timestamp NULL DEFAULT NULL, + `end_date` timestamp NULL DEFAULT NULL, + `created_by` int(11) NOT NULL, + `created_at` int(11) NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `projects` +-- + +DROP TABLE IF EXISTS `projects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `projects` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `description` text NOT NULL, + `requester` varchar(255) NOT NULL, + `created_by` int(11) NOT NULL, + `start_date` timestamp NULL DEFAULT NULL, + `end_date` timestamp NULL DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `roles` +-- + +DROP TABLE IF EXISTS `roles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `roles` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `role` varchar(50) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `sessions` +-- + +DROP TABLE IF EXISTS `sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `sessions` ( + `session_id` varchar(255) NOT NULL, + `data` text DEFAULT NULL, + `ip` varchar(45) DEFAULT NULL, + `agent` varchar(300) DEFAULT NULL, + `stamp` int(11) DEFAULT NULL, + PRIMARY KEY (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tags` +-- + +DROP TABLE IF EXISTS `tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` text NOT NULL, + `color` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_comments` +-- + +DROP TABLE IF EXISTS `ticket_comments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_comments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `comment` text NOT NULL, + `created_by` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `deleted` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `ticket_id` (`ticket_id`) +) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_meta` +-- + +DROP TABLE IF EXISTS `ticket_meta`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_meta` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `meta_key` text NOT NULL, + `meta_value` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_priorities` +-- + +DROP TABLE IF EXISTS `ticket_priorities`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_priorities` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `sort_order` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_relations` +-- + +DROP TABLE IF EXISTS `ticket_relations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_relations` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `parent_ticket_id` int(11) NOT NULL, + `child_ticket_id` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_statuses` +-- + +DROP TABLE IF EXISTS `ticket_statuses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_statuses` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `sort_order` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_tags` +-- + +DROP TABLE IF EXISTS `ticket_tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_tags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_updates` +-- + +DROP TABLE IF EXISTS `ticket_updates`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_updates` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `comment` text NOT NULL, + `updated_by` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tickets` +-- + +DROP TABLE IF EXISTS `tickets`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tickets` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` text NOT NULL, + `description` text NOT NULL, + `status_id` int(11) NOT NULL, + `priority_id` int(11) NOT NULL, + `created_by` int(11) NOT NULL, + `assigned_to` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT NULL, + `updated_by` int(11) DEFAULT NULL, + `project_id` int(11) DEFAULT NULL, + `recycled` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=57 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` text NOT NULL, + `password` text NOT NULL, + `email` text NOT NULL, + `display_name` text NOT NULL, + `role` int(11) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `is_admin` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) USING HASH +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-04-06 12:18:20 + +--- End File: _codebase_schemafile.sql --- + + +--- File: composer.json --- +{ + "name": "tp/tp_servicedesk", + "description": "", + "config": { + "vendor-dir": "lib" + }, + "require": { + "bcosca/fatfree-core": "^3.9", + "erusev/parsedown": "^1.7", + "ezyang/htmlpurifier": "^4.18", + "erusev/parsedown-extra": "^0.8.1", + "singular-it/parsedown-checkbox": "^0.3.5" + } +} + +--- End File: composer.json --- + + +--- File: package.json --- +{ + "dependencies": { + "bulma": "^1.0.3" + }, + "scripts": { + "sass": "sass scss/main.scss public/css/main.css", + "sass:min": "sass scss/main.scss public/css/main.min.css --style compressed", + "sass:watch": "sass --watch scss/main.scss:public/css/main.css" + } +} + +--- End File: package.json --- + + +--- File: app/config/routes.ini --- +[routes] + +; home +GET /=HomeController->display + +; auth +GET /login=AuthController->showLoginForm +POST /login=AuthController->login +GET /logout=AuthController->logout + +; tickets - CRUD (CREATE, READ, UPDATE, DELETE) +GET /tickets=TicketController->index +GET /ticket/@id=TicketController->view +GET /ticket/create=TicketController->createForm +POST /ticket/create=TicketController->create +GET /ticket/@id/edit=TicketController->editForm +POST /ticket/@id/update=TicketController->update +GET /ticket/@id/delete=TicketController->delete +; additional routes - comments +POST /ticket/@id/comment=CommentController->create +GET /ticket/@id/comment/@comment_id/delete=CommentController->delete +GET /ticket/@id/comments=CommentController->index +; route for linking a child to a parent +POST /ticket/@id/add-subtask=TicketController->addSubtask + +; attachments +GET /ticket/@id/attachments=AttachmentController->index +POST /ticket/@id/attachments/upload=AttachmentController->upload +GET /attachment/@id/download=AttachmentController->download +GET /attachment/@id/delete=AttachmentController->delete +GET /attachment/@id/view=AttachmentController->view + +; knowledgebase +GET /kb=KBController->index +GET /kb/@id=KBController->view +GET /kb/create=KBController->createForm +POST /kb/create=KBController->create +GET /kb/@id/edit=KBController->editForm +POST /kb/@id/update=KBController->update + +; tags +GET /tags=TagController->index +GET /tag/create=TagController->createForm +POST /tag/create=TagController->create + +; parsedown preview +POST /parsedown/preview=ParsedownPreview->view + +; toggle-theme +POST /toggle-theme = ThemeController->toggle + +; dashboard +GET /dashboard=DashboardController->index + +; projects +GET /projects=ProjectController->index +GET /project/@id=ProjectController->view +GET /project/create=ProjectController->createForm +POST /project/create=ProjectController->create +GET /project/@id/edit=ProjectController->editForm +POST /project/@id/update=ProjectController->update + +; additional routes - user +GET /users=UserController->index +GET /user/@id/edit=UserController->editForm +POST /user/@id/update=UserController->update + + + +; admin +GET /admin=Admin\HomeController->index +; admin/priority +GET /admin/priority=Admin\TicketOptionsController->listPriorities +GET /admin/priority/create=Admin\TicketOptionsController->createPriorityForm +POST /admin/priority/create=Admin\TicketOptionsController->createPriority +GET /admin/priority/@id/edit=Admin\TicketController->editPriorityForm +POST /admin/priority/@id/update=Admin\TicketController->updatePriority +GET /admin/priority/@id/delete=Admin\TicketController->deletePriority +; admin/status +GET /admin/status=Admin\TicketOptionsController->listStatuses +GET /admin/status/create=Admin\TicketOptionsController->createStatusForm +POST /admin/status/create=Admin\TicketOptionsController->createStatus +GET /admin/status/@id/edit=Admin\TicketController->editStatusForm +POST /admin/status/@id/update=Admin\TicketController->updateStatus +GET /admin/status/@id/delete=Admin\TicketController->deleteStatus +--- End File: app/config/routes.ini --- + + +--- File: app/controllers/AttachmentController.php --- +check_access($f3); + + $ticket_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + // fetch attachments + + $attachments = $db->exec( + 'SELECT a.*, u.username + FROM attachments a + LEFT JOIN users u ON u.id = a.uploaded_by + WHERE a.ticket_id = ? + ORDER BY a.created_at DESC', + [$ticket_id] + ); + + $f3->set('ticket_id', $ticket_id); + $f3->set('attachments', $attachments); + + $f3->set('content', '../ui/views/attachment/index.html'); + // echo \Template::instance()->render('../ui/templates/layout.html'); + echo \Template::instance()->render($f3->get('content')); + } + + // handle file upload + public function upload($f3){ + $this->check_access($f3); + + $ticket_id = (int) $f3->get('PARAMS.id'); + $uploaded_by = $f3->get('SESSION.user.id'); + + if(!isset($_FILES['attachment']) || $_FILES['attachment']['error'] !== UPLOAD_ERR_OK){ + $f3->reroute('/ticket/'.$ticket_id.'/attachments'); + } + + $file_info = $_FILES['attachment']; + $original_name = $file_info['name']; + $tmp_path = $file_info['tmp_name']; + + // create a unique file path + $upload_dir = '../storage/attachments/tickets/'.$ticket_id.'/'; + if(!is_dir($upload_dir)){ + mkdir($upload_dir, 0777, true); + } + + // if file exists increment version + $db = $f3->get('DB'); + $existing = $db->exec( + 'SELECT * FROM attachments + WHERE ticket_id =? AND file_name = ? + ORDER BY version_number DESC + LIMIT 1', + [$ticket_id, $original_name] + ); + + $new_version = 1; + if($existing){ + $new_version = $existing[0]['version_number'] + 1; + } + + $final_path = $upload_dir.$new_version.'_'.$original_name; + + // move file + move_uploaded_file($tmp_path, $final_path); + + // store meta data in DB + $db->exec( + 'INSERT INTO attachments + (ticket_id, path, file_name, version_number, uploaded_by, created_at) + VALUES (?,?,?,?,?,NOW())', + [$ticket_id, $final_path, $original_name, $new_version, $uploaded_by] + ); + + $f3->reroute('/ticket/'.$ticket_id.''); + } + + // download attachment + public function download($f3){ + $this->check_access($f3); + + $attachment_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $rows = $db->exec('SELECT * FROM attachments WHERE id = ?', [$attachment_id]); + + if(!$rows){ + $f3->error(404, "File not found"); + return; + } + + $attachment = $rows[0]; + $file_path = $attachment['path']; + $file_name = $attachment['file_name']; + + // validate file exists + if(!file_exists($file_path)){ + $f3->error(404, "File not found"); + return; + } + + // output headers for download + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="'.basename($file_name).'"'); + header('Content-Length: '. filesize($file_path)); + + // flush headers + flush(); + + // read file + readfile($file_path); + exit; + } + + // delete an attachment + public function delete($f3){ + $this->check_access($f3); + + $attachment_id = (int) $f3->get('PARAMS.id'); + $current_user = $f3->get('SESSION.user'); + + $db = $f3->get('DB'); + + $rows = $db->exec('SELECT * FROM attachments WHERE id =? LIMIT 1', [$attachment_id]); + if(!$rows){ + $f3->error(404, "Attachment not found"); + return; + } + + $attachment = $rows[0]; + // TODO: role or ownership + + if(file_exists($attachment['path'])){ + unlink($attachment['path']); + } + + // remove DB row + $db->exec('DELETE FROM attachments WHERE id =?', [$attachment_id]); + } + + // view attachment + public function view($f3){ + $this->check_access($f3); + + $attachment_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $rows = $db->exec('SELECt * FROM attachments WHERE id = ?', [$attachment_id]); + + if(!$rows){ + $f3->error(404, "File not found"); + return; + } + + $attachment = $rows[0]; + $file_path = $attachment['path']; + $file_name = $attachment['file_name']; + + if(!file_exists($file_path)){ + $f3->error(404, "File not found"); + return; + } + + // detect mime type + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime_type = finfo_file($finfo, $file_path); + finfo_close($finfo); + + header('Content-Type: ' . $mime_type); + header('Content-Disposition: inline; filename="' . basename($file_name) . '"'); + header('Content-Length: ' . filesize($file_path)); + + flush(); + readfile($file_path); + exit; + } +} +--- End File: app/controllers/AttachmentController.php --- + + +--- File: app/controllers/AuthController.php --- +set('error', $f3->get('SESSION.login_error')); + $f3->clear('SESSION.login_error'); + + // this can be in our controller base + $f3->set('content', '../ui/views/login.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('error'); + } + + public function login($f3){ + $username = $f3->get('POST.username'); + $password = $f3->get('POST.password'); + + $db = $f3->get('DB'); + // query for user + $result = $db->exec( + 'SELECT u.id, u.username, u.password, u.role, u.is_admin, r.role as role_name + FROM users u + LEFT JOIN roles r ON r.id = u.role + WHERE username =? + LIMIT 1', $username + ); + + // verifiy password + if($result){ + $user = $result[0]; // first row + if(password_verify($password, $user['password'])){ + // valid + $f3->set('SESSION.user', [ + 'id'=> $user['id'], + 'username' => $user['username'], + 'role' => $user['role'], + 'role_name' => $user['role_name'], + 'is_admin' => $user['is_admin'] + ]); + + if($f3->exists('SESSION.redirect')){ + $redirect = $f3->get('SESSION.redirect'); + $f3->clear('SESSION.redirect'); + $f3->reroute($redirect); + } + + $f3->reroute('/dashboard'); + } else { + $f3->set('SESSION.login_error', 'Invalid password'); + } + } else { + // if here, login failed. + $f3->set('SESSION.login_error', 'Invalid username'); + } + + $f3->reroute('/login'); + + } + + public function logout($f3){ + $f3->clear('SESSION'); + $f3->reroute('/'); + } + +} +--- End File: app/controllers/AuthController.php --- + + +--- File: app/controllers/BaseController.php --- +f3 = \Base::instance(); + + } + + + // helper function + + protected function getDB() + { + return $this->f3->get('DB'); + } + + /** + * Enforce that the user is logged in before proceeding. + */ + protected function requireLogin() + { + // using trait + $this->check_access($this->f3); + return; + + // abstract + if(!$this->f3->exists('SESSION.user')){ + $this->f3->set('SESSION.redirect', $this->f3->get('PATH')); + $this->f3->reroute('/login'); + } + } + + /** + * Enforce that the user is logged in AND is an admin before proceeding. + */ + protected function requireAdmin() + { + $this->requireLogin(); // First, ensure the user is logged in + + // Check if the user is an admin (assuming 'is_admin' property in session) + if (!$this->f3->get('SESSION.user.is_admin')) { + // Optionally set an error message + $this->f3->set('SESSION.error', 'Admin access required.'); + $this->f3->reroute('/'); // Redirect non-admins to home page + } + } + + /** + * Set up a main layout template and inject the specified view path + * optional $data to pass variables down to template + */ + protected function renderView(string $viewPath, array $data = []):void + { + foreach($data as $key => $value){ + $this->f3->set($key, $value); + } + + // set {{content}} + $this->f3->set('content', $viewPath); + + // render tempalte + echo \Template::instance()->render('../ui/templates/layout.html'); + + // clear SESSION.error + $this->f3->clear('SESSION.error'); + } +} +--- End File: app/controllers/BaseController.php --- + + +--- File: app/controllers/CommentController.php --- +exists('SESSION.user')){ + $f3->reroute('/login'); + } + + $ticket_id = (int) $f3->get('PARAMS.id'); + $comment_text = $f3->get('POST.comment'); + $current_user_id = $f3->get('SESSION.user.id'); + + if(empty($comment_text)){ + $f3->set('SESSION.error', 'ticket not updated. No content'); + $f3->reroute('/ticket/' . $ticket_id); + } + + // insert comment + $db = $f3->get('DB'); + $db->exec( + 'INSERT INTO ticket_comments (ticket_id, comment, created_by, created_at) + VALUES (?, ?, ?, NOW())', + [$ticket_id, $comment_text, $current_user_id] + ); + + $f3->reroute('/ticket/' . $ticket_id); + } + + /** + * Delete an existing comment + * Route: GET /tickey/@id/comment/@comment_id/delete + */ + public function delete($f3){ + if(!$f3->exists('SESSION.user')){ + $f3->reroute('/login'); + } + + $ticket_id = (int) $f3->get('PARAMS.id'); + $comment_id = (int) $f3->get('PARAMS.comment_id'); + $current_user = $f3->get('SESSION.user'); + + $db = $f3->get('DB'); + + //optional: check if user is allowed to delete comment. + // fetch who created the comment + $comment_row = $db->exec( + 'SELECT created_by FROM ticket_comments WHERE id = ? AND ticket_id = ? LIMIT 1', + [$comment_id, $ticket_id] + ); + if(!$comment_row){ + $f3->set('SESSION.error', 'Error: Ticket comment ID not found.'); + $f3->reroute('/ticket/'.$ticket_id); + } + $comment_owner = $comment_row[0]['created_by']; + // TODO: $is_admin = () + if($current_user['id'] !== $comment_owner){ + // no permission + $f3->set('SESSION.error', 'You do not have permission to delete this ticket'); + $f3->reroute('/ticket/'. $ticket_id); + } + + // Delete - addition, rather than delete, we set a delete flag + $db->exec('UPDATE ticket_comments SET deleted = 1 WHERE id = ?', [$comment_id]); + $f3->reroute('/ticket/' . $ticket_id); + } + + // view comments + public function index($f3){ + $ticket_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + $results = $db->exec(' + SELECT c.*, u.username AS author_name + FROM ticket_comments c + LEFT JOIN users u ON c.created_by = u.id + WHERE c.ticket_id = ? + ORDER BY c.created_at DESC', + [$ticket_id] + ); + $comments = $results; + $f3->set('comments', $comments); + + echo \Template::instance()->render('../ui/views/comments/view.html'); + } +} +--- End File: app/controllers/CommentController.php --- + + +--- File: app/controllers/DashboardController.php --- +requireLogin(); + + $this->renderView('/ui/views/dashboard.html'); + } +} +--- End File: app/controllers/DashboardController.php --- + + +--- File: app/controllers/HomeController.php --- +renderView('/ui/views/home.html'); + + } + // ... +} +--- End File: app/controllers/HomeController.php --- + + +--- File: app/controllers/KBController.php --- +check_access($f3); + + $db = $f3->get('DB'); + $search_term = $f3->get('GET.search'); + $tag_param = $f3->get('GET.tag'); + + // base query + $sql = 'SELECT a.* FROM kb a'; + $args = []; + + if($tag_param){ + $sql .= ' + JOIN kb_tags AS at ON a.id = at.article_id + JOIN tags t ON at.tag_id = t.id + WHERE t.name = ? + '; + $args[] = $tag_param; + + if($search_term){ + $sql .= ' AND LOWER(a.title) LIKE LOWER(?)'; + $args[] = '%' . $search_term . '%'; + } + } else if ($search_term){ + $sql .= ' WHERE LOWER(a.title) LIKE LOWER(?)'; + $args[] = '%' . $search_term . '%'; + } + + $sql .= ' ORDER BY a.created_at DESC'; + + $articles = $db->exec($sql, $args); + + // render + $f3->set('articles', $articles); + $f3->set('content', '../ui/views/kb/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + + } + + /** + * Form to create new article + */ + public function createForm($f3){ + $this->check_access($f3); + + $db = $f3->get('DB'); + $all_tags = $db->exec('SELECT * FROM tags ORDER BY name ASC'); + $f3->set('all_tags', $all_tags); + + // render + $f3->set('content', '../ui/views/kb/create.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + + } + + // handle POST + public function create($f3){ + $this->check_access($f3); + + $title = $f3->get('POST.title'); + $content = $f3->get('POST.content'); + $created_by = $f3->get('SESSION.user.id'); + + $db = $f3->get('DB'); + + // insert + + $db->exec( + 'INSERT INTO kb (title, content, created_by, updated_by, created_at, updated_at) + VALUES (?,?,?,?, NOW(), NOW())', + [$title, $content, $created_by, $created_by] + ); + + $article_id = $db->lastInsertId(); + + // TODO: tags + + $f3->reroute('/kb'); + } + + // + + protected function check_kb_exists($article_id, $db, $f3){ + $articles = $db->exec( + 'SELECT * FROM kb WHERE id = ? LIMIT 1', [$article_id] + ); + if(!$articles){ + $f3->set('SESSION.error', 'Article not found'); + $f3->reroute('/kb'); + } + return $articles; + } + + // view a single + public function view($f3){ + $this->check_access($f3); + $article_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $articles = $this->check_kb_exists($article_id, $db, $f3); + + $article = $articles[0]; + $f3->set('article', $article); + + // TODO: tags + $tags = $db->exec( + 'SELECT t.* FROM tags AS t + JOIN kb_tags AS at ON t.id = at.tag_id + WHERE at.kb_id = ?', + [$article_id] + ); + + // render + $f3->set('content', '../ui/views/kb/view.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + } + + /** + * Form to edit existing kb article + */ + public function editForm($f3){ + + $this->check_access($f3); + + $article_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $articles = $this->check_kb_exists($article_id, $db, $f3); + + $article = $articles[0]; + $f3->set('article', $article); + + // fetch current tags + $current_tag_ids = $db->exec( + 'SELECT tag_id FROM kb_tags WHERE kb_id = ?', [$article_id] + ); + + $article_tag_ids = array_column($current_tag_ids, 'tag_id'); + $f3->set('article_tag_ids', $article_tag_ids); + + // render + $f3->set('js', 'kb_edit.js'); + $f3->set('content', '../ui/views/kb/edit.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + + } + + /** + * Handle POST to edit existing article + */ + public function update($f3){ + $this->check_access($f3); + $article_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $articles = $this->check_kb_exists($article_id, $db, $f3); + $article = $articles[0]; + + $title = $f3->get('POST.title'); + $content = $f3->get('POST.content'); + $updated_by = $f3->get('SESSION.user.id'); + + $db->exec( + 'UPDATE kb + SET title=?, content=?, updated_by =?, updated_at = NOW() + WHERE id = ?', + [$title, $content, $updated_by, $article_id] + ); + + // update tags - first delete + $db->exec('DELETE FROM kb_tags WHERE kb_id = ?', [$article_id]); + + $tags_id = $f3->get('POST.tags'); + if(!empty($tags_id) && is_array($tags_id)){ + foreach($tags_id as $tag_id){ + $db->exec( + 'INSERT IGNORE INTO kb_tags (article_id, tag_id) VALUES (?,?)', + [$article_id, $tag_id] + ); + } + } + + $f3->reroute('/kb/'.$article_id); + + } + + + + +} +--- End File: app/controllers/KBController.php --- + + +--- File: app/controllers/ParsedownPreview.php --- +get('POST.content'); + echo Parsedown::instance()->text($preview_text); + + } + +} +--- End File: app/controllers/ParsedownPreview.php --- + + +--- File: app/controllers/ProjectController.php --- +check_access($f3); + + $db = $f3->get('DB'); + + // retrieve projects + $projects = $db->exec('SELECT * FROM projects ORDER BY created_at DESC'); + + $f3->set('projects', $projects); + + + $f3->set('content', '../ui/views/project/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + + $f3->clear('SESSION.error'); + } + + // create a new project + public function createForm($f3){ + $this->check_access($f3); + $f3->set('content', '../ui/views/project/create.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + + } + + public function create($f3){ + + } + + // show project details including links, tickets, events, tasks + public function view($f3){ + $this->check_access($f3); + + $project_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $result = $db->exec( + 'SELECT * FROM projects WHERE id = ? LIMIT 1', [$project_id] + ); + $project = $result[0]; + $f3->set('project', $project); + + $f3->set('content', '../ui/views/project/view.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + // update project details + public function editForm($f3){ + + $this->check_access($f3); + $f3->set('content', '../ui/views/project/edit.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + + } + + public function update($f3){} + + } +--- End File: app/controllers/ProjectController.php --- + + +--- File: app/controllers/TagController.php --- +check_access($f3); + + $db = $f3->get('DB'); + $tags = $db->exec('SELECT * FROM tags ORDER BY name ASC'); + $f3->set('tags', $tags); + + $f3->set('content', '../ui/views/tag/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function createForm($f3){ + $this->check_access($f3); + + $f3->set('content', '../ui/views/tag/create.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function create($f3){ + $this->check_access($f3); + + $name = $f3->get('POST.name'); + $color = $f3->get('POST.color'); + $db = $f3->get('DB'); + + // insert new tag + $db->exec('INSERT IGNORE INTO tags (name, color) VALUES (?, ?)', [$name, $color]); + $f3->reroute('/tags'); + } + + public function view($f3) + { + + } + + public function editForm($f3) + { + + } + + public function update($f3) + { + + } + +} +--- End File: app/controllers/TagController.php --- + + +--- File: app/controllers/ThemeController.php --- +get('SESSION.theme') ?: 'light'; + $new_theme = ($current === 'light') ? 'dark' : 'light'; + $f3->set('SESSION.theme', $new_theme); + + $f3->reroute($f3->get('HEADERS.Referer') ?: '/'); + } +} +--- End File: app/controllers/ThemeController.php --- + + +--- File: app/controllers/TicketController.php --- +requireLogin(); + + $filter = $f3->get('GET.status'); + + // retrieve tickets + $ticket_mapper = new Ticket($this->getDB()); + + if($filter){ + $tickets = $ticket_mapper->findFiltered($filter); + } else { + $tickets = $ticket_mapper->findAll(); + } + + // render + $this->renderView('../ui/views/ticket/index.html', + ['tickets' => $tickets] + ); + + $f3->clear('SESSION.error'); + } + + // view a single ticket + // TODO_PROJECTS: show a link back to the related project + public function view($f3){ + $this->requireLogin(); + + $ticket_id = $f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + // render + $this->renderView('../ui/views/ticket/view.html', [ + 'ticket' => $ticket, + 'attachments' => $ticket->attachments(), + 'comments' => $ticket->comments(), + 'parent_tickets' => $ticket->getParentTickets(), + 'child_tickets' => $ticket->getChildTickets(), + 'ticket_meta' => $ticket->getMetaAssoc() + ]); + + } + + // show create form + // TODO_PROJECTS: dropdown to associate ticket with project + public function createForm($f3){ + $db = $this->getDB(); + $priorities = (new TicketPriority($db))->findAll(); + $statuses = (new TicketStatus($db))->findAll(); + + $this->requireLogin(); + $this->renderView('../ui/views/ticket/create.html',[ + 'priorities' => $priorities, + 'statuses' => $statuses + ]); + } + + // handle POST + // including custom forms + public function create($f3){ + + $this->requireLogin(); + + $data = [ + 'title' => $this->f3->get('POST.title'), + 'created_at' => $this->f3->get('POST.created_at'), + 'description' => $this->f3->get('POST.description'), + 'priority_id' => $this->f3->get('POST.priority_id'), + 'status_id' => $this->f3->get('POST.status_id'), + 'created_by' => $this->f3->get('SESSION.user.id') + ]; + + $ticket_mapper = new Ticket($this->getDB()); + $new_ticket_id = $ticket_mapper->createTicket($data); + + // custom field + $meta_keys = $this->f3->get('POST.meta_key'); + $meta_values = $this->f3->get('POST.meta_value'); + $meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values); + $ticket_mapper->setCustomFields($meta_assoc); + + $this->f3->reroute('/ticket/' . $new_ticket_id); + } + + // show edit form + // including custom forms + // TODO_PROJECTS: allow reasssigning or removing a project association + public function editForm($f3) + { + $this->requireLogin(); + + $ticket_id = $f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Ticket not found.'); + $this->f3->reroute('/tickets'); + } + + // + $f3->set('js', 'markdown_preview.js'); + + // dropdowns + $priorities = (new TicketPriority($this->getDB()))->findAll(); + $statuses = (new TicketStatus($this->getDB()))->findAll(); + + $this->renderView('../ui/views/ticket/edit.html',[ + 'ticket' => $ticket, + 'ticket_meta' => $ticket->getMeta(), + 'priorities' => $priorities, + 'statuses' => $statuses + ] + ); + return; + } + + // process edit POST TODO: if assigned or admin + public function update($f3) + { + + $this->requireLogin(); + + $ticket_id = $this->f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Ticket not found.'); + $this->f3->reroute('/tickets'); + } + + $data = [ + 'title' => $this->f3->get('POST.title'), + 'created_at' => $this->f3->get('POST.created_at'), + 'description' => $this->f3->get('POST.description'), + 'priority_id' => $this->f3->get('POST.priority_id'), + 'status_id' => $this->f3->get('POST.status_id'), + 'updated_by' => $this->f3->get('SESSION.user.id') + ]; + $ticket->updateTicket($data); + + // deal with meta data / custom fields + $meta_keys = $this->f3->get('POST.meta_key'); + $meta_values = $this->f3->get('POST.meta_value'); + $meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values); + $ticket->setCustomFields($meta_assoc); + + $f3->reroute('/ticket/' . $ticket_id); + } + + // subtask + public function addSubtask($f3){ + $this->requireLogin(); + + $parent_id = (int) $f3->get('PARAMS.id'); + $child_id = (int) $f3->get('POST.child_ticket_id'); + + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($parent_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Parent Ticket not found'); + $this->f3->reroute('/tickets'); + } + $ticket->addChildTicket($child_id); + $this->f3->reroute('/ticket/' . $parent_id); + } + + public function delete(): void + { + $this->requireLogin(); + + $ticket_id = (int)$this->f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Ticket not found'); + $this->f3->reroute('/tickets'); + } + + $ticket->softDelete(); + $this->f3->reroute('/tickets'); + } + +} +--- End File: app/controllers/TicketController.php --- + + +--- File: app/controllers/UserController.php --- +check_access($f3); + + $db = $f3->get('DB'); + $users = $db->exec( + 'SELECT u.*, r.role AS role_name + FROM users u + LEFT JOIN roles r ON r.id = u.role + ORDER BY id ASC' + ); + $f3->set('users', $users); + + $f3->set('content', '../ui/views/user/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function editForm($f3){ + $this->check_access($f3); + + $user_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $rows = $db->exec( + 'SELECt * FROM users WHERE id = ? LIMIT 1', + [$user_id] + ); + if(!$rows){ + $f3->reroute('/users'); + } + $f3->set('edit_user', $rows[0]); + $f3->set('content', '../ui/views/user/edit.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function update($f3){ + + $this->check_access($f3); + + $user_id = (int) $f3->get('PARAMS.id'); + $new_username = $f3->get('POST.username'); + // $new_role = $f3->get('POST.role_name') + $db = $f3->get('DB'); + $db->exec( + 'UPDATE users SET username = ? WHERE id =? LIMIT 1', + [$new_username, $user_id]); + $f3->reroute('/users'); + } + + public function createForm($f3) + { + + } + + public function create($f3) + { + + } + + public function view($f3) + { + + } + +} +--- End File: app/controllers/UserController.php --- + + +--- File: app/controllers/Admin/HomeController.php --- +renderView('/ui/views/admin/index.html'); + } +} +--- End File: app/controllers/Admin/HomeController.php --- + + +--- File: app/controllers/Admin/TicketOptionsController.php --- +requireLogin(); + $this->requireAdmin(); // Added admin check + + $model = new \TicketPriority($this->getDB()); + $priorities = $model->findAll(); + + $this->renderView('/ui/views/admin/priorities/index.html', [ + 'priorities' => $priorities + ]); + } + + public function createPriorityForm() + { + $this->requireLogin(); + $this->requireAdmin(); // Added admin check + $this->renderView('/ui/views/admin/priorities/create.html'); + } + + public function createPriority() + { + $this->requireLogin(); + $this->requireAdmin(); // Added admin check + $p = new \TicketPriority($this->getDB()); + $p->name = $this->f3->get('POST.name'); + $p->sort_order = $this->f3->get('POST.sort_order'); + $p->save(); + + // Redirect after save + $this->f3->reroute('/admin/priorities'); + } + + public function editPriorityForm($f3, $params) + { + $this->requireLogin(); + $this->requireAdmin(); + $priorityId = $params['id']; + + $model = new \TicketPriority($this->getDB()); + $priority = $model->load(['id = ?', $priorityId]); + + if (!$priority) { + $f3->error(404, 'Priority not found'); + return; + } + + $this->renderView('/ui/views/admin/priorities/edit.html', [ + 'priority' => $priority + ]); + } + + public function updatePriority($f3, $params) + { + $this->requireLogin(); + $this->requireAdmin(); + $priorityId = $params['id']; + + $model = new \TicketPriority($this->getDB()); + $priority = $model->load(['id = ?', $priorityId]); + + if (!$priority) { + $f3->error(404, 'Priority not found'); + return; + } + + $priority->name = $this->f3->get('POST.name'); + $priority->sort_order = $this->f3->get('POST.sort_order'); + $priority->save(); + + // Redirect after update + $this->f3->reroute('/admin/priorities'); + } + + public function deletePriority($f3, $params) + { + $this->requireLogin(); + $this->requireAdmin(); + $priorityId = $params['id']; + + $model = new \TicketPriority($this->getDB()); + $priority = $model->load(['id = ?', $priorityId]); + + if (!$priority) { + // Optionally show an error message or just redirect + $this->f3->reroute('/admin/priorities'); + return; + } + + $priority->erase(); + + // Redirect after delete + $this->f3->reroute('/admin/priorities'); + } + +} +--- End File: app/controllers/Admin/TicketOptionsController.php --- + + +--- File: app/controllers/Admin/UserController.php --- +build($label); + $name = \Template::instance()->build($name); + $value = \Template::instance()->build($value); + $selected = \Template::instance()->build($selected); + + if(defined("BulmaFormHelper::$type")){ + + $type_const = constant("BulmaFormHelper::$type"); + + switch( $type_const ){ + case BulmaFormHelper::H_FIELD_INPUT: + return BulmaFormHelper::build_h_field_input($label, $name, $value); + break; + case BulmaFormHelper::H_FIELD_TEXTAREA: + return BulmaFormHelper::build_h_field_textarea($label, $name, $value); + break; + case BulmaFormHelper::H_FIELD_SELECT: + return BulmaFormHelper::build_h_field_select($label, $name, $options, $selected); + break; + case BulmaFormHelper::H_FIELD_SELECT_NEW: + return BulmaFormHelper::build_h_field_select_new($attr); + break; + case BulmaFormHelper::FIELD_INPUT: + return BulmaFormHelper::build_field_input($label, $name, $value, $class); + break; + case BulmaFormHelper::FIELD_TEXTAREA: + return BulmaFormHelper::build_field_textarea($label, $name, $value, $class, $rows); + break; + case BulmaFormHelper::FIELD_SELECT: + return BulmaFormHelper::build_field_select($attr); + break; + default: + return '
Error: Bulma CSS Form TYPE ('.$type.') not defined.
'; + break; + } + + } else { + return '
Error: Bulma CSS Form TYPE not defined.
'; + } + + } + + static function build_field_input($label, $name, $value, $class, $rows=10){ + + $string_label = $label !== '' ? sprintf('', $label) : ''; + $string = ' +
+ %1$s +
+ +
+
+ '; + return sprintf($string, $string_label, $name, $value, $class, $rows); + } + + static function build_field_textarea($label, $name, $value, $class, $rows=10) + { + $string_label = $label !== '' ? sprintf('', $label) : ''; + $string = ' +
+ %1$s +
+ +
+
+ '; + return sprintf($string, $string_label, $name, $value, $class,$rows); + } + + static function build_h_field_textarea($label, $name, $value){ + $string = ' +
+
+ +
+
+
+
+ +
+
+
+
+ '; + return $string; + } + + static function build_h_field_input($label, $name, $value){ + $string = ' +
+
+ +
+
+
+
+ +
+
+
+
+ '; + return $string; + } + + + /** + * build_field_select_new + * + * `` + * + * @param mixed $attr + * @return void + */ + static function build_field_select($attr) + { + $f3 = \Base::instance(); + + $class = $attr['class'] ?? ''; + $label = $attr['label'] ?? ''; + $name = $attr['name'] ?? ''; + // $options_arr = $attr['options'] ?? []; + $option_value = $attr['option_value'] ?? 'id'; + $option_name = $attr['option_name'] ?? 'name'; + + $options = \Template::instance()->token($attr['options']); + $selected = \Template::instance()->token($attr['selected']); + + // TODO: label - this could be moved into a seperate function + $html_label = $label !== '' ? sprintf('', $label) : ''; + + $tmp_options = 'field_select('. + $options.', '.$selected.', "'.$option_value.'", "'.$option_name.'"); ?>'; + + $html = ' +
+ %1$s +
+
+ +
+
+
+ '; + + return sprintf($html, $html_label, $tmp_options, $name, $class); + } + + function field_select($options, $selected, $option_value, $option_name){ + $html_options = ''; + foreach ($options as $option) { + $value = $option[$option_value] ?? ''; + $text = $option[$option_name] ?? ''; + $html_selected = ((string)$value === (string)$selected) ? ' selected="selected"' : ''; + $html_option = ''; + $html_options .= sprintf($html_option, $value, $html_selected, $text); + } + echo $html_options; + } + + static function build_h_field_select_new($attr) + { + $f3 = \Base::instance(); + + $label = $attr['label'] ?? ''; + $name = $attr['name'] ?? ''; + $options_arr = $attr['options'] ?? []; + $optionValue = $attr['option_value'] ?? 'id'; + $optionName = $attr['option_name'] ?? 'name'; + $selected = $attr['selected'] ?? ''; + + $options = $f3->get($options_arr); + + $html = '
'; + if (!empty($label)) { + $html .= ''; + } + $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= '
'; + + return $html; + } + + + + static function build_h_field_select($label, $name, $options, $selected){ + $opts = json_decode(str_replace("'", '"', $options)); + $opts_string = ""; + foreach($opts as $k => $v){ + if($v == $selected){ + $selected_str = " selected"; + } else { + $selected_str = ""; + } + $opts_string .= ''.$v.''; + } + + $string = + '
+
+ +
+
+
+
+ +
+
+
+
+ '; + return $string; + } + +} + +\Template::instance()->extend('bulma', 'BulmaFormHelper::render'); +--- End File: app/extensions/BulmaFormHelper.php --- + + +--- File: app/extensions/IconsHelper.php --- + ['fas fa-circle-dot has-text-success', "new"], + 'in_progress' => ['fas fa-circle-play has-text-link', "reload"], + 'on_hold' => ['fas fa-pause-circle has-text-warning',"pause"], + 'completed' => ['fas fa-check has-text-danger', "check"] + ]; + + static public $status_names = [ + 'open' => 'Open', + 'in_progress' => 'In Progress', + 'on_hold' => 'On Hold', + 'completed' => 'Completed' + ]; + + static public $priority_icons = [ + 'Low' => ['fas fa-circle-down',"green"], + 'Medium' => ['fas fa-circle-dot', "yellow"], + 'High' => ['fas fa-circle-up', "red"] + ]; + + static public $priority_colors = [ + 'Low' => 'success', + 'Medium' => 'warning', + 'High' => 'danger', + '' => 'info' + ]; + + static public function icons($node){ + + $attr = $node['@attrib']; + + $tpl = Template::instance(); + $f3 = Base::instance(); + + $context = $f3->hive(); + $inner = $tpl->token($node[0], $context); + + return ''; + + } + + static function do_the_switch($type, $value){ + + if($value !== null) { + $value = str_replace(' ', '_', strtolower($value)); + } + $icon_class = ''; + switch(strtolower($type)){ + case 'status': + $icon_class = IconsHelper::$status_icons[$value] ?? ['fas fa-question-circle has-text-info', "🔲"]; + break; + case 'priority': + $icon_class = IconsHelper::$priority_icons[$value] ?? ['fas fa-question-circle', "🔲"]; + $icon_color = IconsHelper::$priority_colors[$value] ?? 'info'; + break; + default: + $icon_class = 'fas fa-question-circle'; + } + + if($type == 'priority'){ + // return '

' + return ' + + '; + } else { + return ''; + } + return ''.$icon_class[1].''; + + } + +} + +\Template::instance()->extend('icons', 'IconsHelper::icons'); +--- End File: app/extensions/IconsHelper.php --- + + +--- File: app/extensions/ParsedownHelper.php --- +text($args[0]); + + return ' +

+ '.$return.' +
+ '; + + } + + // return '
'.print_r($args,1).'
'; + + $content = $args[0]; + $content_token = \Template::instance()->token($content); + + return ' + + build('.$content_token.'); ?> + '; + } + + function build($content){ + return \ParsedownTableExtension::instance()->text($content); + } +} + +\Template::instance()->extend('parsedown', 'ParsedownHelper::render'); +--- End File: app/extensions/ParsedownHelper.php --- + + +--- File: app/extensions/ParsedownTableExtension.php --- + 'table', + // 'handler' => 'elements', + // 'text' => [ ... ], + // 'attributes' => [...], + // ] + + // Add your custom class to the itself: + if (!isset($Block['element']['attributes'])) { + $Block['element']['attributes'] = []; + } + $Block['element']['attributes']['class'] = 'table is-bordered'; + + // Wrap the
in a
: + $wrapped = [ + 'name' => 'div', + 'attributes' => [ + 'class' => 'table-container', + ], + 'handler' => 'elements', + 'text' => [ + $Block['element'], // the
itself + ], + ]; + + // Replace the original element with our wrapped version: + $Block['element'] = $wrapped; + } + + return $Block; + } +} +--- End File: app/extensions/ParsedownTableExtension.php --- + + +--- File: app/interfaces/CRUD.php --- +db->exec( + 'SELECT a.*, u.username + FROM attachments a + LEFT JOIN users u ON u.id = a.uploaded_by + WHERE a.ticket_id = ? + ORDER BY a.created_at DESC', + [$ticket_id] + ); + } + +} +--- End File: app/models/Attachment.php --- + + +--- File: app/models/Comment.php --- +db->exec( + 'SELECT c.*, u.username AS author_name + FROM ticket_comments c + LEFT JOIN users u ON c.created_by = u.id + WHERE c.ticket_id = ? + ORDER BY c.created_at DESC', + [$ticket_id] + ); + } + } +--- End File: app/models/Comment.php --- + + +--- File: app/models/Tag.php --- +tag_table = $type . '_tags'; + $this->tag_table_id = $type . '_id'; + parent::__construct($db, $this->tag_table); + } + return $this; + } + + // VERIFY: possible issue with this? + public function getTagsFor($objects, $id_key = 'id') + { + // echo $this->get('_type_id'); exit; + // printf('
%s
', print_r($this,1)); exit; + if(empty($objects)) return []; + $ids = array_column($objects, $id_key); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $sql = + 'SELECT tt.%1$s, t.id, t.name, t.color + FROM %2$s tt + INNER JOIN tags t ON tt.tag_id = t.id + WHERE tt.%1$s IN (%3$s)'; + $sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table, $placeholders); + $rows = $this->db->exec($sql_sprintf, $ids); + + $tags_map = []; + foreach($rows as $row) + { + $tags_map[$row[$this->tag_table_id]][] = $row; + } + + foreach($objects as &$object) + { + $object['tags'] = $tags_map[$object[$id_key]] ?? []; + } + return $objects; + + } + + public function getTagsForID($id, $id_key = 'id') + { + $sql = 'SELECT tt.%1$s, t.id, t.name, t.color + FROM %2$s tt + INNER JOIN tags t ON tt.tag_id = t.id + WHERE tt.%1$s = ?'; + $sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table); + $rows = $this->db->exec($sql_sprintf, $id); + return $rows; + } + + public function findLinkedTags($id = '') + { + $sql = ' + SELECT t.name, t.color + FROM `?` tt + LEFT JOIN `tags` t ON t.id = tt.id + WHERE tt.`?` = ? + '; + $params = [ + $this->_type, + $this->_type_id, + $id + ]; + return $this->db->exec($sql, $params); + } + + +} +--- End File: app/models/Tag.php --- + + +--- File: app/models/Ticket.php --- +db->exec( + 'SELECT t.id, t.title, t.created_at, + tp.name AS priority_name, ts.name AS status_name, u.display_name + FROM tickets t + LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id + LEFT JOIN ticket_statuses ts ON t.status_id = ts.id + LEFT JOIN users u ON t.created_by = u.id + WHERE t.recycled = 0 + ORDER BY t.created_at DESC' + ); + $result = $this->getTagsForTickets($tickets); + return $result; + } + + public function findFiltered(string $filter): array + { + $sql = ' + SELECT t.*, tp.name AS priority_name, ts.name AS status_name, u.display_name + FROM tickets t + LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id + LEFT JOIN ticket_statuses ts ON t.status_id = ts.id + LEFT JOIN users u ON t.created_by = u.id + WHERE t.recycled = 0 + '; + $params = []; + switch($filter){ + case 'open': + $sql .= ' AND status_id = ?'; + $params[] = 1; + break; + case 'in_progress': + $sql .= ' AND status_id = ?'; + $params[] = 2; + break; + case 'on_hold': + $sql .= ' AND status_id = ?'; + $params[] = 3; + break; + case 'completed': + $sql .= ' AND status_id = ?'; + $params[] = 4; + break; + } + + $sql .= ' ORDER BY t.created_at DESC'; + $tickets = $this->db->exec($sql, $params); + $result = $this->getTagsForTickets($tickets); + return $result; + } + + public function getTagsForTickets(array $tickets) + { + $tag_mapper = new Tag($this->db, 'ticket'); + $tickets = $tag_mapper->getTagsFor($tickets); + + return $tickets; + } + + public function findById($id): ?Ticket + { + $this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id'; + $this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id'; + $this->load(['id = ?', $id]); + $this->tags = (new Tag($this->db,'ticket'))->getTagsForID($id, 'ticket_id'); + return $this->dry() ? null : $this; + } + + public function createTicket(array $data): int + { + $this->reset(); + + $this->title = $data['title'] ?? ''; + $this->description = $data['description'] ?? ''; + // + $this->priority_id = $data['priority_id'] ?? null; + $this->status_id = $data['status_id'] ?? null; + // + $this->created_by = $data['created_by'] ?? null; + $this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s'); + $this->updated_at = date('Y-m-d H:i:s'); + + $this->save(); + return (int)$this->id; + } + + public function updateTicket(array $data): void + { + if(isset($data['title'])){ $this->title = $data['title']; } + if(isset($data['description'])) { $this->description = $data['description']; } + if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; } + if(isset($data['status_id'])) { $this->status_id = $data['status_id']; } + if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; } + $this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s'); + $this->updated_at = date('Y-m-d H:i:s'); + $this->save(); + } + + public function softDelete():void { + $this->recycled = 1; + $this->save(); + } + + public function attachments(){ + $attachment = new Attachment($this->db); + return $attachment->findWithUserByTicketId($this->id); + } + + public function comments(){ + $comment = new Comment($this->db); + return $comment->findWithUserByTicketId($this->id); + } + + public function getParentTickets() + { + return $this->db->exec( + 'SELECT p.* + FROM ticket_relations r + INNER JOIN tickets p ON r.parent_ticket_id = p.id + WHERE r.child_ticket_id = ?', + [$this->id] + ); + } + + public function getChildTickets() + { + return $this->db->exec( + 'SELECT c.* + FROM ticket_relations r + INNER JOIN tickets c ON r.child_ticket_id = c.id + WHERE r.parent_ticket_id = ?', + [$this->id] + ); + } + + public function addChildTicket(int $childId) + { + $this->db->exec( + 'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id) + VALUES (?, ?)', + [$this->id, $childId] + ); + } + + // meta data + public function getMeta() + { + return $this->db->exec( + 'SELECT id, meta_key, meta_value + FROM ticket_meta + WHERE ticket_id = ?', + [$this->id] + ); + } + + public function getMetaAssoc() + { + $rows = $this->getMeta(); + $assoc = []; + foreach($rows as $row){ + $assoc[$row['meta_key']] = $row['meta_value']; + } + return $assoc; + } + + public function assocExistingMeta($meta_ids, $meta_keys, $meta_values){ + if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){ + $field_assoc = []; + foreach($meta_ids as $i => $m_id){ + $key = $meta_keys[$i] ?? ''; + $value = $meta_values[$i] ?? ''; + if(!empty($key) && $value !== ''){ + $field_assoc[$key] = $value; + } + } + return $field_assoc; + } + return []; + } + + public function assocMetaFromKeyValue($meta_keys, $meta_values) + { + if(is_array($meta_keys) && is_array($meta_values)){ + $field_assoc = []; + foreach($meta_keys as $i => $key){ + $val = $meta_values[$i] ?? ''; + if(!empty($key) && $val != ''){ + $field_assoc[$key] = $val; + } + } + return $field_assoc; + } + return []; + } + + public function setCustomFields(array $fields) + { + $this->db->exec( + 'DELETE FROM ticket_meta WHERE ticket_id = ?', [$this->id] + ); + + foreach($fields as $key => $value){ + $this->db->exec( + 'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value) + VALUES (?, ?, ?)', + [$this->id, $key, $value] + ); + } + } + +} +--- End File: app/models/Ticket.php --- + + +--- File: app/models/TicketPriority.php --- +db->exec( + 'SELECT * FROM ticket_priorities ORDER BY sort_order ASC' + ); + } +} +--- End File: app/models/TicketPriority.php --- + + +--- File: app/models/TicketStatus.php --- +db->exec( + 'SELECT * FROM ticket_statuses ORDER BY sort_order ASC' + ); + } +} +--- End File: app/models/TicketStatus.php --- + + +--- File: app/traits/RequiresAuth.php --- +exists('SESSION.user')){ + // $f3->set('SESSION.error', 'You don\'t have permission for this ticket.'); + $f3->set('SESSION.redirect', $f3->get('PATH')); + $f3->reroute('/login'); + } + } + +} +--- End File: app/traits/RequiresAuth.php --- + + +--- File: public/index.php --- +config('../app/config/.env.cfg'); +$f3->set('DEBUG', 3); // development debug +$f3->set('CACHE', FALSE); + +/** + * Not required yet + */ +$htmlpurifier = \HTMLPurifier::instance(); +// $htmlpurifier->purify($input); +$md = \ParsedownTableExtension::instance(); +$md->setSafeMode(true); + +$f3->set('EXT', [new ParsedownHelper, new BulmaFormHelper, new IconsHelper]); + +$f3->set('DB', new \DB\SQL( + 'mysql:host=localhost;port=3306;dbname=' . $f3->get('database.db_name'), + $f3->get('database.username'), + $f3->get('database.password') +)); + +new \DB\SQL\Session($f3->get('DB')); +$f3->set('SESSION.status', 'running'); + +$f3->run(); +--- End File: public/index.php --- + + +--- File: public/logo.svg --- + +--- End File: public/logo.svg --- + + +--- File: public/style.css --- +html, body {padding:0; margin:0;} +html, body, #sidebar, #page,#base_body { + min-height: 100% +} + +#page { min-height: calc(100vh - 170px - 52px) } +i.fa { font-weight: 100 !important ; } + +.table th.th-icon { width: 2rem; } + +#ticket_list .g-flex-item { border-bottom: 1px solid var(--bulma-text-soft); } + +a { word-break: break-word; } + +/* parsedown check-checkbox */ +li.parsedown-task-list { + list-style: none; +} + +/* List Component */ +.list{ + --be-list-color:var(--bulma-text); + --be-list-item-description-color:var(--bulma-text-50); + --be-list-item-divider-color:var(--bulma-border); + --be-list-item-hover-color:var(--bulma-scheme-main-bis); + --be-list-item-image-margin:.75em; + --be-list-item-padding:.75em; + --be-list-item-title-color:var(--bulma-text-strong); + --be-list-item-title-weight:var(--bulma-weight-semibold); + color:var(--be-list-color); + flex-direction:column; + display:flex +} +.list.has-hidden-images .list-item-image{ + display:none +} +.list.has-hoverable-list-items .list-item:hover{ + background-color:var(--be-list-item-hover-color) +} +.list.has-overflow-ellipsis .list-item-content{ + min-inline-size:0; + max-inline-size:calc(var(--length)*1ch) +} +.list.has-overflow-ellipsis .list-item-content>*{ + text-overflow:ellipsis; + white-space:nowrap; + overflow:hidden +} +@media (hover:hover){ + .list:not(.has-visible-pointer-controls) .list-item-controls{ + opacity:0; + visibility:hidden + } +} +.list .list-item{ + align-items:center; + transition:background-color .125s ease-out; + display:flex; + position:relative; + /* TP: update + align top */ + align-items: flex-start; +} +@media (hover:hover){ + .list .list-item:hover .list-item-controls,.list .list-item:focus-within .list-item-controls{ + opacity:initial; + visibility:initial + } +} +.list .list-item:not(.box){ + padding-block:var(--be-list-item-padding); + padding-inline:var(--be-list-item-padding) +} +.list .list-item:not(:last-child):not(.box){ + border-block-end:1px solid var(--be-list-item-divider-color) +} +@media screen and (width<=768px){ + .list:not(.has-overflow-ellipsis) .list .list-item{ + flex-wrap:wrap + } +} +.list .list-item-image{ + flex-shrink:0; + margin-inline-end:var(--be-list-item-image-margin); + /* TP: update + add margin-top */ + margin-top: 0.5rem; +} +@media screen and (width<=768px){ + .list .list-item-image{ + padding-block:.5rem; + padding-inline:0 + } +} +.list .list-item-content{ + flex-direction:column; + flex-grow:1; + display:flex +} +@media screen and (width<=768px){ + .list .list-item-content{ + padding-block:.5rem; + padding-inline:0 + } +} +.list .list-item-title{ + color:var(--be-list-item-title-color); + font-weight:var(--be-list-item-title-weight); + margin-bottom: .25rem; +} +.list .list-item-description{ + color:var(--be-list-item-description-color) +} +.list .list-item-controls{ + flex-shrink:0; + transition:opacity .125s ease-out +} +@media screen and (width<=768px){ + .list .list-item-controls{ + flex-wrap:wrap; + padding-block:.5rem; + padding-inline:0 + } +} +@media screen and (width>=769px),print{ + .list .list-item-controls{ + padding-inline-start:var(--be-list-item-padding) + } + .list:not(.has-visible-pointer-controls) .list .list-item-controls{ + block-size:100%; + align-items:center; + padding-block-end:var(--be-list-item-padding); + display:flex; + position:absolute; + inset-inline-end:0 + } +} +--- End File: public/style.css --- + + +--- File: public/test.md.php --- + + + + + + + + + + + + +

MD Testing

+ +1. MD CONTENT +2. list item two + +and something else + +- and then +- and then +- and then + + +--- End File: public/test.md.php --- + + +--- File: public/js/kb_edit.js --- + + +// switch to target tab pane +function switchTab(targetId){ + var panes = document.querySelectorAll('.tab-content .tab-pane'); + for (var i=0; i< panes.length; i++){ + panes[i].style.display = 'none'; + } + var targetPane = document.getElementById(targetId); + if(targetPane){ + targetPane.style.display = 'block'; + } +} + +// send ajax post request with content to specified url +function ajaxPost(content, url, callback){ + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + xhr.onreadystatechange = function(){ + if(xhr.readyState === XMLHttpRequest.DONE){ + if(xhr.status === 200){ + callback(xhr.responseText); + } else { + console.error("AJAX error: " + xhr.status); + } + } + }; + var params = 'content=' + encodeURIComponent(content); + xhr.send(params); +} + +// load preview via ajax into preview element +function loadPreview(previewElement){ + var sourceId = previewElement.getAttribute('data-source'); + var handlerUrl = previewElement.getAttribute('data-handler'); + var method = previewElement.getAttribute('data-method'); + var sourceElement = document.getElementById(sourceId); + if(sourceElement){ + var content = sourceElement.value; + if(method && method.toLowerCase() == 'post'){ + ajaxPost(content, handlerUrl, function (response){ + previewElement.innerHTML = response; + }); + } + } +} + +// initialise tab links to handle tab switching +function initTabs(){ + var tabLinks = document.querySelectorAll('.tabs a[data-target]'); + for(var i=0; i { + link.addEventListener('click', (e) => this.handleTabClick(e, link)); + }); + } + + async handleTabClick(e, link) { + e.preventDefault(); + const selectedTab = link.getAttribute('data-tab'); + + // Update active tab + this.tabParent.querySelectorAll('li').forEach(li => li.classList.remove('is-active')); + link.parentElement.classList.add('is-active'); + + // Show active content + document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none'); + const activeContent = document.getElementById(`${this.contentPrefix}-${selectedTab}`); + if (activeContent) activeContent.style.display = ''; + + if (selectedTab === 'preview') { + await this.loadPreview(); + } + } + + async loadPreview() { + const previewTarget = document.getElementById('preview-output'); + if (!previewTarget) return; + + previewTarget.innerHTML = ` +
+
+
+ `; + + await new Promise(resolve => setTimeout(resolve, 500)); + + const textarea = document.querySelector(this.textareaSelector); + const markdown = textarea ? textarea.value : ''; + + const res = await fetch(this.previewUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `content=${encodeURIComponent(markdown)}` + }); + + const html = await res.text(); + previewTarget.innerHTML = html; + } + } + + // Usage + document.addEventListener('DOMContentLoaded', () => { + new TabSwitcherController({ + tabSelector: '.tabs', + contentPrefix: 'tab', + textareaSelector: '#description', + previewUrl: '/parsedown/preview' + }); + }); + +--- End File: public/js/markdown_preview.js --- + + +--- File: public/js/ticket_view.js --- +document.addEventListener('DOMContentLoaded', function(){ + const ticket_id = window.location.pathname.split('/')[2]; + const comments_url = `/ticket/${ticket_id}/comments`; + const attachments_url = `/ticket/${ticket_id}/attachments`; + + function ajax(url, containerID){ + fetch(url) + .then(response => { + if(!response.ok){ + throw new Error('Network response was not ok.'); + } + return response.text(); + }) + .then(html => { + const container_el = document.getElementById(containerID); + if(container_el){ + container_el.innerHTML += html; + } else { + throw new Error('Coments container does not exist'); + } + }) + .catch(error => { + console.log('Error fetching comments', error); + }); + } + + ajax(attachments_url, 'attachments') + ajax(comments_url, 'comments') +}); + + +--- End File: public/js/ticket_view.js --- + + +--- File: public/js/tp_md_editor.js --- +/** + * tp_md_editor.js + * Self-contained Markdown Editor with Toolbar Buttons + * Usage: tp_md_editor.init(config) + */ + +class TPMarkdownEditor { + static init(config = {}) { + document.querySelectorAll('tp-md-editor').forEach(editor => { + new TPMarkdownEditor(editor, config); + }); + } + + constructor(wrapper, config) { + this.wrapper = wrapper; + this.name = wrapper.getAttribute('name'); + this.config = config; + this.textarea = document.createElement('textarea'); + this.textarea.name = this.name; + this.textarea.rows = 25; + this.toolbar = document.createElement('tp-md-toolbar'); + + this.undoStack = [] + this.redoStack = [] + this.localStorageKey = this.getStorageKey(); + this.unsaved = false; + this.autosaveInterval = null; + + // this.loadInitialContent(); + this.captureInitialContent(); + this.createUnsavedBanner(); + this.loadFromLocalStorage(); + + this.wrapper.appendChild(this.unsavedBanner); + this.wrapper.appendChild(this.toolbar); + this.wrapper.appendChild(this.textarea); + + this.buttonClasses = TPMarkdownEditor.defaultButtons(); + this.buildToolbar(); + this.setupAutoList(); + this.setupUndoRedo(); + this.setupPersistence(); + this.setupAutoSave(); + } + + getStorageKey(){ + const path = window.location.pathname; + return `tp_md_editor:${path}:${this.name}`; + } + + createUnsavedBanner(){ + const container = document.createElement('div'); + container.style.cssText = 'background: #fff3cd; color: #856404; padding: 5px 10px; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center; display: none;'; + + const text = document.createElement('span'); + text.textContent = 'You have unsaved changes.'; + + const discardBtn = document.createElement('button'); + discardBtn.textContent = 'Discard'; + discardBtn.style.cssText = 'margin-left: auto; background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer;'; + discardBtn.addEventListener('click', () => { + localStorage.removeItem(this.localStorageKey); + const hidden = this.wrapper.querySelector('.tp-md-initial'); + if(hidden){ + this.textarea.value = hidden.textContent; + } + this.clearUnsaved(); + }); + + container.appendChild(text); + container.appendChild(discardBtn); + this.unsavedBanner = container + } + + markUnsaved(){ + this.unsaved = true; + this.unsavedBanner.style.display = 'block'; + } + + clearUnsaved(){ + this.unsaved = false; + this.unsavedBanner.style.display = 'none'; + } + + setupPersistence(){ + window.addEventListener('beforeunload', (e) => { + if(this.unsaved){ + localStorage.setItem(this.localStorageKey, this.textarea.value); + } + }); + } + + setupAutoSave(){ + this.autosaveInterval = setInterval(() => { + if(this.unsaved){ + localStorage.setItem(this.localStorageKey, this.textarea.value); + } + }, 5000); // save every 5 sec + } + + loadFromLocalStorage(){ + const saved = localStorage.getItem(this.localStorageKey); + if(saved){ + this.textarea.value = saved; + this.markUnsaved(); + } + } + + buildToolbar() { + const groups = this.config.groups || [Object.keys(this.buttonClasses)]; + groups.forEach(group => { + const groupEl = document.createElement('tp-md-toolbar-group'); + group.forEach(btnType => { + const BtnClass = this.buttonClasses[btnType]; + if (BtnClass) { + const btn = new BtnClass(this.textarea); + groupEl.appendChild(btn.element); + } + }); + this.toolbar.appendChild(groupEl); + }); + } + + setupUndoRedo(){ + this.textarea.addEventListener('input', ()=>{ + this.undoStack.push(this.textarea.value); + if(this.undoStack > 100) this.undoStack.shift(); + this.redoStack = []; + this.markUnsaved(); + }); + + this.textarea.addEventListener('keydown', (e) => { + if(e.ctrlKey && e.key === 'z'){ + e.preventDefault(); + if(this.undoStack.length > 0 ){ + this.redoStack.push(this.textarea.value); + this.textarea.value = this.undoStack.pop(); + this.markUnsaved(); + } + } else if (e.ctrlKey && (e.key === 'y')) { // || e.shiftkey && e.key === 'z' + e.preventDefault(); + if(this.redoStack.length > 0){ + this.undoStack.push(this.textarea.value); + this.textarea.value = this.redoStack.pop(); + this.markUnsaved(); + } + } + }); + } + + setupAutoList() { + this.textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const pos = this.textarea.selectionStart; + const before = this.textarea.value.slice(0, pos); + const after = this.textarea.value.slice(pos); + const lines = before.split('\n'); + const lastLine = lines[lines.length - 1]; + + // need order of task > ul + let match; + if ((match = lastLine.match(/^(\s*)- \[( |x)\] /))) { + // task + e.preventDefault(); + if (lastLine.trim() === '- [ ]' || lastLine.trim() === '- [x]') { + this.removeLastLine(pos, lastLine.length); + } else { + const insert = '\n' + match[1] + '- [ ] '; + this.insertAtCursor(insert); + } + } else if ((match = lastLine.match(/^(\s*)([-*+] )/))) { + // ul + e.preventDefault(); + if (lastLine.trim() === match[2].trim()) { + this.removeLastLine(pos, lastLine.length); + } else { + const insert = '\n' + match[1] + match[2]; + this.insertAtCursor(insert); + } + } else if ((match = lastLine.match(/^(\s*)(\d+)\. /))) { + // ol + e.preventDefault(); + if (lastLine.trim() === `${match[2]}.`) { + this.removeLastLine(pos, lastLine.length); + } else { + const nextNum = parseInt(match[2]) + 1; + const insert = `\n${match[1]}${nextNum}. `; + this.insertAtCursor(insert); + } + } + } + }); + } + + removeLastLine(cursorPos, lengthToRemove) { + const start = cursorPos - lengthToRemove; + this.textarea.setRangeText('', start, cursorPos, 'start'); + this.textarea.setSelectionRange(start, start); + } + + insertAtCursor(text) { + const start = this.textarea.selectionStart; + const end = this.textarea.selectionEnd; + this.textarea.setRangeText(text, start, end, 'end'); + const newPos = start + text.length; + this.textarea.setSelectionRange(newPos, newPos) + this.markUnsaved(); + } + + captureInitialContent() { + const hidden = document.createElement('script'); + hidden.type = 'text/plain'; + // hidden.style.display = 'none'; + hidden.classList.add('tp-md-initial'); + hidden.textContent = this.wrapper.textContent.trim(); + // clear inner content so it's not visible twice + this.wrapper.textContent = ''; + + this.wrapper.appendChild(hidden); + this.textarea.value = hidden.textContent; + } + + static defaultButtons() { + return { + h1: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '# ', 'H1', 'fas fa-heading', '', 'fas fa-1'); } + }, + h2: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '## ', 'H2', 'fas fa-heading', '', 'fas fa-2'); } + }, + h3: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '### ', 'H3', 'fas fa-heading', '', 'fas fa-3'); } + }, + bold: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '**', 'Bold', 'fas fa-bold', '**'); } + }, + italic: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '_', 'Italic', 'fas fa-italic', '_'); } + }, + quote: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '> ', 'Quote', 'fas fa-quote-right'); } + }, + code: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '`', 'Code', 'fas fa-code', '`'); } + formatSelection(sel) { + if (!sel || !sel.includes('\n')) { + return '`' + (sel || 'code') + '`'; + } + return '```\n' + sel + '\n```'; + } + }, + link: class extends TPMarkdownButton { + constructor(textarea) { + super(textarea, '[', 'Link', 'fas fa-link', '](url)'); + } + formatSelection(sel) { + return `[${sel || 'text'}](url)`; + } + }, + bullet: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '- ', 'Bullet', 'fas fa-list-ul'); } + formatSelection(sel){ + return sel.split('\n').map(line => '- ' + line).join('\n'); + } + }, + number: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '1. ', 'Numbered', 'fas fa-list-ol'); } + formatSelection(sel){ + return sel.split('\n').map((line, i) => `${i+1}. ${line}`).join('\n'); + } + }, + task: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '- [ ] ', 'Task', 'fas fa-tasks'); } + formatSelection(sel){ + return sel.split('\n').map(line => ' - [ ]' + line).join('\n') + } + }, + hr: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '---\n', 'HR', 'fas fa-minus'); } + }, + table: class extends TPMarkdownButton { + constructor(textarea) { + super(textarea, '', 'Table', 'fas fa-table'); + } + formatSelection(_) { + return '| Col1 | Col2 |\n|------|------|\n| Val1 | Val2 |'; + } + }, + }; + } +} + +class TPMarkdownButton { + constructor(textarea, prefix = '', title = '', icon = '', suffix = '', icon_offset = '') { + this.textarea = textarea; + this.prefix = prefix; + this.suffix = suffix; + this.element = document.createElement('tp-md-toolbar-button'); + this.element.title = title; + if (icon_offset == '') { + this.element.innerHTML = ``; + } else { + this.element.innerHTML = ``; + } + this.element.addEventListener('click', () => this.apply()); + } + + formatSelection(sel) { + return this.prefix + (sel || 'text') + this.suffix; + } + + apply() { + const textarea = this.textarea; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + this.previousValue = textarea.value; + const selected = text.substring(start, end); + const formatted = this.formatSelection(selected); + textarea.setRangeText(formatted, start, end, 'end'); + + if(this.previousValue !== textarea.value){ + if(!textarea.undoStack) textarea.undoStack = []; + textarea.undoStack.push(this.previousValue); + } + + textarea.focus(); + } +} + +// Export as global +window.tp_md_editor = TPMarkdownEditor; +--- End File: public/js/tp_md_editor.js --- + + +--- File: scss/main.scss --- +// import bulma +@use "vendor/bulma"; + +// import custom components +@use "components/ticket-item"; +--- End File: scss/main.scss --- + + +--- File: scss/components/_ticket-item.scss --- +@use "../vendor/bulma"; + +.ticket-item { + @extend .is-flex; + @extend .mb-1; + @extend .pt-1; + @extend .pb-2; + @extend .is-align-items-flex-start; + border-bottom: 1px solid var(--bulma-text-90); + + .ticket-icon { + @extend .mr-2; + display: flex; + align-items: baseline; + + .checkbox { + margin-right: 0.5rem; + } + } + + .ticket-content { + @extend .is-flex; + @extend .is-flex-direction-column; + @extend .is-flex-grow-1; + align-self: baseline; + + .ticket-header { + @extend .is-flex; + @extend .is-justify-content-flex-start; + @extend .is-flex-wrap-wrap; + @extend .mb-1; + align-items: center; + + .ticket-title { + @extend .title; + @extend .mb-0; + @extend .is-5; + font-weight: normal; + } + + .tags { + @extend .ml-2; + } + } + + .ticket-meta { + @extend .is-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + + p { + @extend .subtitle; + @extend .is-6; + font-weight: 300; + margin: 0; + } + } + } +} +--- End File: scss/components/_ticket-item.scss --- + + +--- File: scss/vendor/_bulma-tools.scss --- +@use "../../node_modules/bulma/sass/utilities/" as bulma-utils; +@use "../../node_modules/bulma/sass/helpers/" as bulma-helpers; +@use "../../node_modules/bulma/sass/elements/" as bulma-elements; +--- End File: scss/vendor/_bulma-tools.scss --- + + +--- File: scss/vendor/_bulma.scss --- +@forward "../../node_modules/bulma/bulma"; +--- End File: scss/vendor/_bulma.scss --- + + +--- File: ui/partials/ticket_item.html --- +
+
+
+ +
+ + {{@ticket.status_name}} +
+
+
+ + {{ @ticket.title }} + +
+ + {{ @tag.name }} + +
+
+
+

#{{ @ticket.id }} opened {{ @ticket.created_at }} by {{ @ticket.display_name }}

+
+
+
+--- End File: ui/partials/ticket_item.html --- + + +--- File: ui/parts/clipboard.html --- +
+ + +
+ Paste or drag an image here +
+ +

+ + +
+--- End File: ui/parts/clipboard.html --- + + +--- File: ui/session/error.html --- + +
+ {{ @SESSION.error }} +
+
+--- End File: ui/session/error.html --- + + +--- File: ui/templates/layout.html --- + + + + + + + TP ServiceDesk + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + +
+
+ + +
+
+

© + Terry Probert +

+
+
+ + + + + + +--- End File: ui/templates/layout.html --- + + +--- File: ui/views/dashboard.html --- +

Dashboard

+--- End File: ui/views/dashboard.html --- + + +--- File: ui/views/home.html --- + + +
+
+
+

TP ServiceDesk

+

One place to manage requests, store knowledge, and collaborate on projects

+

+ + + Get Started + + + + Browse Knowledge Base + +

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

Ticketing System

+
    +
  • Create & Track tickets
  • +
  • Assign priorities & statuses
  • +
  • Link child/parent tickets
  • +
+
+
+
+ + +
+
+
+

Knowledge Base

+
    +
  • Markdown-powered articles
  • +
  • Tagging and filtering
  • +
  • Fast searching
  • +
+
+
+
+ + +
+
+
+

Projects

+
    +
  • Track ongoing projects
  • +
  • Integreate tasks and tickets
  • +
  • Monitor progress
  • +
+
+
+
+ + +
+
+
+

Collaboration

+
    +
  • Comment threads
  • +
  • File attachments
  • +
  • Role-based user access
  • +
+
+
+
+ + +
+
+
+

Custom fields

+
    +
  • Define ticket meta data
  • +
  • Configure and store extra info
  • +
  • Easily editable in forms
  • +
+
+
+
+ + +
+
+
+

Administration

+
    +
  • Manage user roles
  • +
  • Create new account
  • +
  • Edit existing users
  • +
+
+
+
+ + + +
+
+
+--- End File: ui/views/home.html --- + + +--- File: ui/views/login.html --- +

Please Log In

+ + +
+

{{ @error }}

+
+
+ +
+ +
+

+ + + + +

+
+
+

+ + + + +

+
+
+

+ +

+
+ + +--- End File: ui/views/login.html --- + + +--- File: ui/views/admin/index.html --- +

Admin

+
+ +
+

Ticket > Priorities

+

Ticket > Statuses

+
+--- End File: ui/views/admin/index.html --- + + +--- File: ui/views/admin/priorities/create.html --- +

Create Ticket Priority

+

TODO:

+--- End File: ui/views/admin/priorities/create.html --- + + +--- File: ui/views/admin/priorities/index.html --- +

Admin: Ticket Priorities

+

create priority

+
+ +
+ + + + + + + + + + + + + + + + + + + +
idnamesort_order
{{@priority.id}}{{@priority.name}}{{@priority.sort_order}} + + + + +
+--- End File: ui/views/admin/priorities/index.html --- + + +--- File: ui/views/attachment/index.html --- +
+
+

Attachments

+
+ + + + + + + + + + + + + + + + + + + + + + + +
File NameUploaded ByCreated AtVersion
+ + {{ @attach.file_name }}{{ @attach.username }}{{ @attach.created_at }}{{ @attach.version_number }}
+
+
+ + + +
+
+
+
+
+
+
+ + + + +
+
+ +
+
+
+
+
+
+
+--- End File: ui/views/attachment/index.html --- + + +--- File: ui/views/comments/view.html --- +
+
+

Comments

+ +
+ +
+
+
+ + +
+
+
+
+ {{ @comment.author_name}} + {{ @comment.created_at }} +
+
+ {{ @comment.comment | raw }} +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+--- End File: ui/views/comments/view.html --- + + +--- File: ui/views/kb/create.html --- +

Create Knowledge Base Article

+ + +
+
+ + + +
+
+ +
+ +
+
+ +
+
+ +
+
+
+ + + + +
+
+ Cancel +
+
+ +
+
+
+
+--- End File: ui/views/kb/create.html --- + + +--- File: ui/views/kb/edit.html --- +

Edit Knowledge Base Article

+ + +
+ + + +
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ + + + + +
+--- End File: ui/views/kb/edit.html --- + + +--- File: ui/views/kb/index.html --- +

Knowledge Base

+

create kb article

+
+ +
+
+
+
+ + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
idtitlecreated_at
{{@article.id}}{{@article.title}}{{@article.created_at}} + +
+
+ + +
+

No articles found.

+
+
+--- End File: ui/views/kb/index.html --- + + +--- File: ui/views/kb/view.html --- +

{{@article.title}}

+

edit article

+
+ +
+ {{ @article.content | raw }} +
+ + + + +
+ + + + + + + + + +
PropertyValue
{{@key}} {{@value}}
+
+
+ +--- End File: ui/views/kb/view.html --- + + +--- File: ui/views/project/create.html --- +
+    TODO: create form.
+
+--- End File: ui/views/project/create.html --- + + +--- File: ui/views/project/edit.html --- +
+    TODO: edit form
+
+--- End File: ui/views/project/edit.html --- + + +--- File: ui/views/project/index.html --- +

Projects

+

create project

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleRequesterCreated ByCreated AtStart DateEnd Date
{{ @p.id }}{{ @p.title }}{{ @p.requester }}{{ @p.created_by }}{{ @p.created_at }}{{ @p.start_date }}{{ @p.end_date }}
+ +--- End File: ui/views/project/index.html --- + + +--- File: ui/views/project/view.html --- +

{{ @project.title }}

+

edit project

+
+
+
+

Overview

+
+ {{ @project.description }} +
+
+
+
+

Links

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

Tickets

+
+
+
+
+
+
+
+
+
+

Tasks

+
+
+
+
+
+
+
+
+
+

Events

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

Timeline

+
+
+
+
+
+
+
+
+
+ + +--- +## View project + +A central place to see everything for this project: + +- Overview: (title, description, links, start/end dates). +- related tickets (with status and priorities) +- events +- tasks +- timeline combining events, tickets, milestone dates + +## Example Workflow + +- create a project - `team manager overview` +-- attach relevant links +- add tickets - each new request or issue can be a ticket referencing this project +- add events - quick notes about management meetings, or verbal discussions that don't need ticket overhead +-- meeting on 01 jan to discuss layout +-- teams message on 28 jan clarifying data requirements +- project tasks - for smaller to do items that don't warrant a full ticket +-- identify location of required data, create initial pq connections, build a mockup layout + +## Reporting Timelines +- timeline view - merge ticket data with project_events sorted by date - chronological Overview +- status summaries - how many tickets open, on hold, completed +- progress tracking - sumarries or gantt style charts + +--- End File: ui/views/project/view.html --- + + +--- File: ui/views/tag/create.html --- +

Create Tag

+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ Cancel +
+
+ +
+
+
+
+--- End File: ui/views/tag/create.html --- + + +--- File: ui/views/tag/index.html --- +

Tags

+

create tag

+
+ + + +
+ + {{ @tag.name }} + +
+
+ +
+ No tags found +
+
+
+ + +
+
+

Color Examples

+

The following color names can be used for tags

+
+
+ Black + Dark + Light + White + Primary + Link + Info + Success + Warning + Danger +
+
+--- End File: ui/views/tag/index.html --- + + +--- File: ui/views/ticket/create.html --- +

Create Ticket Form

+ +
+ + + + + + + + + + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ Cancel +
+
+ +
+
+ +
+--- End File: ui/views/ticket/create.html --- + + +--- File: ui/views/ticket/edit.html --- + + +
+
+
+ +
+
+ +
+ Cancel +
+
+ +
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+ +
+
+ + + + +
+ +
+ + + + + + + + + + + +
PropertyValue
{{@key}} {{@value}}
+
+ +
+

Linked Tickets

+ + +
+

Parent Tickets

+ +
+ + + +
+

Child Tickets

+
+
+ + +
+ +
+ +
+
+
+
+ +
+
+ + */ ?> +
+
+
+
+ +
+ +
+ + + + + + + + + +
+ +--- End File: ui/views/ticket/edit.html --- + + +--- File: ui/views/ticket/edit.html.v1 --- +

Edit Ticket Form

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

Custom Fields

+ +
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+--- End File: ui/views/ticket/edit.html.v1 --- + + +--- File: ui/views/ticket/index.html --- +

Tickets

+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+ + + +
+ + + + + + +*/ +?> +--- End File: ui/views/ticket/index.html --- + + +--- File: ui/views/ticket/index_row.html --- +
+
+ + {{@ticket.status_name}} +
+
+
+
+ {{@ticket.title}} +
+
+ + tag +
+
+
+

#{{@ticket.id}} opened 2025-03-25 by {{@ticket.display_name}}

+
+
+
+--- End File: ui/views/ticket/index_row.html --- + + +--- File: ui/views/ticket/view.html --- + +
+

{{ @ticket.title }}

+ +
+ +
+ +
+ +
+
+
+

{{ @ticket.created_at }}

+
+ {{ @ticket.description | raw }} +
+
+
+ +
+ +
+
+ + {{@tag.name}} + +
+ + + + + + + + + + + +
PropertyValue
{{@key}} {{@value}}
+
+ +
+

Linked Tickets

+ + +
+

Parent Tickets

+ +
+
+ + +
+

Child Tickets

+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+ + + +
+ + + + + + +
+--- End File: ui/views/ticket/view.html --- + + +--- File: ui/views/user/edit.html --- + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+--- End File: ui/views/user/edit.html --- + + +--- File: ui/views/user/index.html --- +
+

All Users

+ + + + + + + + + + + + + + +
IDUsernameRoleActions
{{ @u.id }}{{ @u.username }}{{ @u.role_name }} ( {{ @u.role }} )
+
+--- End File: ui/views/user/index.html --- + +============================================================ +End of Codebase +============================================================ +--- End File: _codebase_output.txt --- + + +--- File: _codebase_schemafile.sql --- + +--- End File: _codebase_schemafile.sql --- + + +--- File: composer.json --- +{ + "name": "tp/tp_servicedesk", + "description": "", + "config": { + "vendor-dir": "lib" + }, + "require": { + "bcosca/fatfree-core": "^3.9", + "erusev/parsedown": "^1.7", + "ezyang/htmlpurifier": "^4.18", + "erusev/parsedown-extra": "^0.8.1", + "singular-it/parsedown-checkbox": "^0.3.5" + } +} + +--- End File: composer.json --- + + +--- File: package.json --- +{ + "dependencies": { + "bulma": "^1.0.3" + }, + "scripts": { + "sass": "sass scss/main.scss public/css/main.css", + "sass:min": "sass scss/main.scss public/css/main.min.css --style compressed", + "sass:watch": "sass --watch scss/main.scss:public/css/main.css" + } +} + +--- End File: package.json --- + + +--- File: app/config/routes.ini --- +[routes] + +; home +GET /=HomeController->display + +; auth +GET /login=AuthController->showLoginForm +POST /login=AuthController->login +GET /logout=AuthController->logout + +; tickets - CRUD (CREATE, READ, UPDATE, DELETE) +GET /tickets=TicketController->index +GET /ticket/@id=TicketController->view +GET /ticket/create=TicketController->createForm +POST /ticket/create=TicketController->create +GET /ticket/@id/edit=TicketController->editForm +POST /ticket/@id/update=TicketController->update +GET /ticket/@id/delete=TicketController->delete +; additional routes - comments +POST /ticket/@id/comment=CommentController->create +GET /ticket/@id/comment/@comment_id/delete=CommentController->delete +GET /ticket/@id/comments=CommentController->index +; route for linking a child to a parent +POST /ticket/@id/add-subtask=TicketController->addSubtask + +; attachments +GET /ticket/@id/attachments=AttachmentController->index +POST /ticket/@id/attachments/upload=AttachmentController->upload +GET /attachment/@id/download=AttachmentController->download +GET /attachment/@id/delete=AttachmentController->delete +GET /attachment/@id/view=AttachmentController->view + +; knowledgebase +GET /kb=KBController->index +GET /kb/@id=KBController->view +GET /kb/create=KBController->createForm +POST /kb/create=KBController->create +GET /kb/@id/edit=KBController->editForm +POST /kb/@id/update=KBController->update + +; tags +GET /tags=TagController->index +GET /tag/create=TagController->createForm +POST /tag/create=TagController->create + +; parsedown preview +POST /parsedown/preview=ParsedownPreview->view + +; toggle-theme +POST /toggle-theme = ThemeController->toggle + +; dashboard +GET /dashboard=DashboardController->index + +; projects +GET /projects=ProjectController->index +GET /project/@id=ProjectController->view +GET /project/create=ProjectController->createForm +POST /project/create=ProjectController->create +GET /project/@id/edit=ProjectController->editForm +POST /project/@id/update=ProjectController->update + +; additional routes - user +GET /users=UserController->index +GET /user/@id/edit=UserController->editForm +POST /user/@id/update=UserController->update + + + +; admin +GET /admin=Admin\HomeController->index +; admin/priority +GET /admin/priority=Admin\TicketOptionsController->listPriorities +GET /admin/priority/create=Admin\TicketOptionsController->createPriorityForm +POST /admin/priority/create=Admin\TicketOptionsController->createPriority +GET /admin/priority/@id/edit=Admin\TicketController->editPriorityForm +POST /admin/priority/@id/update=Admin\TicketController->updatePriority +GET /admin/priority/@id/delete=Admin\TicketController->deletePriority +; admin/status +GET /admin/status=Admin\TicketOptionsController->listStatuses +GET /admin/status/create=Admin\TicketOptionsController->createStatusForm +POST /admin/status/create=Admin\TicketOptionsController->createStatus +GET /admin/status/@id/edit=Admin\TicketController->editStatusForm +POST /admin/status/@id/update=Admin\TicketController->updateStatus +GET /admin/status/@id/delete=Admin\TicketController->deleteStatus +--- End File: app/config/routes.ini --- + + +--- File: app/controllers/AttachmentController.php --- +check_access($f3); + + $ticket_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + // fetch attachments + + $attachments = $db->exec( + 'SELECT a.*, u.username + FROM attachments a + LEFT JOIN users u ON u.id = a.uploaded_by + WHERE a.ticket_id = ? + ORDER BY a.created_at DESC', + [$ticket_id] + ); + + $f3->set('ticket_id', $ticket_id); + $f3->set('attachments', $attachments); + + $f3->set('content', '../ui/views/attachment/index.html'); + // echo \Template::instance()->render('../ui/templates/layout.html'); + echo \Template::instance()->render($f3->get('content')); + } + + // handle file upload + public function upload($f3){ + $this->check_access($f3); + + $ticket_id = (int) $f3->get('PARAMS.id'); + $uploaded_by = $f3->get('SESSION.user.id'); + + if(!isset($_FILES['attachment']) || $_FILES['attachment']['error'] !== UPLOAD_ERR_OK){ + $f3->reroute('/ticket/'.$ticket_id.'/attachments'); + } + + $file_info = $_FILES['attachment']; + $original_name = $file_info['name']; + $tmp_path = $file_info['tmp_name']; + + // create a unique file path + $upload_dir = '../storage/attachments/tickets/'.$ticket_id.'/'; + if(!is_dir($upload_dir)){ + mkdir($upload_dir, 0777, true); + } + + // if file exists increment version + $db = $f3->get('DB'); + $existing = $db->exec( + 'SELECT * FROM attachments + WHERE ticket_id =? AND file_name = ? + ORDER BY version_number DESC + LIMIT 1', + [$ticket_id, $original_name] + ); + + $new_version = 1; + if($existing){ + $new_version = $existing[0]['version_number'] + 1; + } + + $final_path = $upload_dir.$new_version.'_'.$original_name; + + // move file + move_uploaded_file($tmp_path, $final_path); + + // store meta data in DB + $db->exec( + 'INSERT INTO attachments + (ticket_id, path, file_name, version_number, uploaded_by, created_at) + VALUES (?,?,?,?,?,NOW())', + [$ticket_id, $final_path, $original_name, $new_version, $uploaded_by] + ); + + $f3->reroute('/ticket/'.$ticket_id.''); + } + + // download attachment + public function download($f3){ + $this->check_access($f3); + + $attachment_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $rows = $db->exec('SELECT * FROM attachments WHERE id = ?', [$attachment_id]); + + if(!$rows){ + $f3->error(404, "File not found"); + return; + } + + $attachment = $rows[0]; + $file_path = $attachment['path']; + $file_name = $attachment['file_name']; + + // validate file exists + if(!file_exists($file_path)){ + $f3->error(404, "File not found"); + return; + } + + // output headers for download + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="'.basename($file_name).'"'); + header('Content-Length: '. filesize($file_path)); + + // flush headers + flush(); + + // read file + readfile($file_path); + exit; + } + + // delete an attachment + public function delete($f3){ + $this->check_access($f3); + + $attachment_id = (int) $f3->get('PARAMS.id'); + $current_user = $f3->get('SESSION.user'); + + $db = $f3->get('DB'); + + $rows = $db->exec('SELECT * FROM attachments WHERE id =? LIMIT 1', [$attachment_id]); + if(!$rows){ + $f3->error(404, "Attachment not found"); + return; + } + + $attachment = $rows[0]; + // TODO: role or ownership + + if(file_exists($attachment['path'])){ + unlink($attachment['path']); + } + + // remove DB row + $db->exec('DELETE FROM attachments WHERE id =?', [$attachment_id]); + } + + // view attachment + public function view($f3){ + $this->check_access($f3); + + $attachment_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $rows = $db->exec('SELECt * FROM attachments WHERE id = ?', [$attachment_id]); + + if(!$rows){ + $f3->error(404, "File not found"); + return; + } + + $attachment = $rows[0]; + $file_path = $attachment['path']; + $file_name = $attachment['file_name']; + + if(!file_exists($file_path)){ + $f3->error(404, "File not found"); + return; + } + + // detect mime type + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime_type = finfo_file($finfo, $file_path); + finfo_close($finfo); + + header('Content-Type: ' . $mime_type); + header('Content-Disposition: inline; filename="' . basename($file_name) . '"'); + header('Content-Length: ' . filesize($file_path)); + + flush(); + readfile($file_path); + exit; + } +} +--- End File: app/controllers/AttachmentController.php --- + + +--- File: app/controllers/AuthController.php --- +set('error', $f3->get('SESSION.login_error')); + $f3->clear('SESSION.login_error'); + + // this can be in our controller base + $f3->set('content', '../ui/views/login.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('error'); + } + + public function login($f3){ + $username = $f3->get('POST.username'); + $password = $f3->get('POST.password'); + + $db = $f3->get('DB'); + // query for user + $result = $db->exec( + 'SELECT u.id, u.username, u.password, u.role, u.is_admin, r.role as role_name + FROM users u + LEFT JOIN roles r ON r.id = u.role + WHERE username =? + LIMIT 1', $username + ); + + // verifiy password + if($result){ + $user = $result[0]; // first row + if(password_verify($password, $user['password'])){ + // valid + $f3->set('SESSION.user', [ + 'id'=> $user['id'], + 'username' => $user['username'], + 'role' => $user['role'], + 'role_name' => $user['role_name'], + 'is_admin' => $user['is_admin'] + ]); + + if($f3->exists('SESSION.redirect')){ + $redirect = $f3->get('SESSION.redirect'); + $f3->clear('SESSION.redirect'); + $f3->reroute($redirect); + } + + $f3->reroute('/dashboard'); + } else { + $f3->set('SESSION.login_error', 'Invalid password'); + } + } else { + // if here, login failed. + $f3->set('SESSION.login_error', 'Invalid username'); + } + + $f3->reroute('/login'); + + } + + public function logout($f3){ + $f3->clear('SESSION'); + $f3->reroute('/'); + } + +} +--- End File: app/controllers/AuthController.php --- + + +--- File: app/controllers/BaseController.php --- +f3 = \Base::instance(); + + } + + + // helper function + + protected function getDB() + { + return $this->f3->get('DB'); + } + + /** + * Enforce that the user is logged in before proceeding. + */ + protected function requireLogin() + { + // using trait + $this->check_access($this->f3); + return; + + // abstract + if(!$this->f3->exists('SESSION.user')){ + $this->f3->set('SESSION.redirect', $this->f3->get('PATH')); + $this->f3->reroute('/login'); + } + } + + /** + * Enforce that the user is logged in AND is an admin before proceeding. + */ + protected function requireAdmin() + { + $this->requireLogin(); // First, ensure the user is logged in + + // Check if the user is an admin (assuming 'is_admin' property in session) + if (!$this->f3->get('SESSION.user.is_admin')) { + // Optionally set an error message + $this->f3->set('SESSION.error', 'Admin access required.'); + $this->f3->reroute('/'); // Redirect non-admins to home page + } + } + + /** + * Set up a main layout template and inject the specified view path + * optional $data to pass variables down to template + */ + protected function renderView(string $viewPath, array $data = []):void + { + foreach($data as $key => $value){ + $this->f3->set($key, $value); + } + + // set {{content}} + $this->f3->set('content', $viewPath); + + // render tempalte + echo \Template::instance()->render('../ui/templates/layout.html'); + + // clear SESSION.error + $this->f3->clear('SESSION.error'); + } +} +--- End File: app/controllers/BaseController.php --- + + +--- File: app/controllers/CommentController.php --- +exists('SESSION.user')){ + $f3->reroute('/login'); + } + + $ticket_id = (int) $f3->get('PARAMS.id'); + $comment_text = $f3->get('POST.comment'); + $current_user_id = $f3->get('SESSION.user.id'); + + if(empty($comment_text)){ + $f3->set('SESSION.error', 'ticket not updated. No content'); + $f3->reroute('/ticket/' . $ticket_id); + } + + // insert comment + $db = $f3->get('DB'); + $db->exec( + 'INSERT INTO ticket_comments (ticket_id, comment, created_by, created_at) + VALUES (?, ?, ?, NOW())', + [$ticket_id, $comment_text, $current_user_id] + ); + + $f3->reroute('/ticket/' . $ticket_id); + } + + /** + * Delete an existing comment + * Route: GET /tickey/@id/comment/@comment_id/delete + */ + public function delete($f3){ + if(!$f3->exists('SESSION.user')){ + $f3->reroute('/login'); + } + + $ticket_id = (int) $f3->get('PARAMS.id'); + $comment_id = (int) $f3->get('PARAMS.comment_id'); + $current_user = $f3->get('SESSION.user'); + + $db = $f3->get('DB'); + + //optional: check if user is allowed to delete comment. + // fetch who created the comment + $comment_row = $db->exec( + 'SELECT created_by FROM ticket_comments WHERE id = ? AND ticket_id = ? LIMIT 1', + [$comment_id, $ticket_id] + ); + if(!$comment_row){ + $f3->set('SESSION.error', 'Error: Ticket comment ID not found.'); + $f3->reroute('/ticket/'.$ticket_id); + } + $comment_owner = $comment_row[0]['created_by']; + // TODO: $is_admin = () + if($current_user['id'] !== $comment_owner){ + // no permission + $f3->set('SESSION.error', 'You do not have permission to delete this ticket'); + $f3->reroute('/ticket/'. $ticket_id); + } + + // Delete - addition, rather than delete, we set a delete flag + $db->exec('UPDATE ticket_comments SET deleted = 1 WHERE id = ?', [$comment_id]); + $f3->reroute('/ticket/' . $ticket_id); + } + + // view comments + public function index($f3){ + $ticket_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + $results = $db->exec(' + SELECT c.*, u.username AS author_name + FROM ticket_comments c + LEFT JOIN users u ON c.created_by = u.id + WHERE c.ticket_id = ? + ORDER BY c.created_at DESC', + [$ticket_id] + ); + $comments = $results; + $f3->set('comments', $comments); + + echo \Template::instance()->render('../ui/views/comments/view.html'); + } +} +--- End File: app/controllers/CommentController.php --- + + +--- File: app/controllers/DashboardController.php --- +requireLogin(); + + $this->renderView('/ui/views/dashboard.html'); + } +} +--- End File: app/controllers/DashboardController.php --- + + +--- File: app/controllers/HomeController.php --- +renderView('/ui/views/home.html'); + + } + // ... +} +--- End File: app/controllers/HomeController.php --- + + +--- File: app/controllers/KBController.php --- +check_access($f3); + + $db = $f3->get('DB'); + $search_term = $f3->get('GET.search'); + $tag_param = $f3->get('GET.tag'); + + // base query + $sql = 'SELECT a.* FROM kb a'; + $args = []; + + if($tag_param){ + $sql .= ' + JOIN kb_tags AS at ON a.id = at.article_id + JOIN tags t ON at.tag_id = t.id + WHERE t.name = ? + '; + $args[] = $tag_param; + + if($search_term){ + $sql .= ' AND LOWER(a.title) LIKE LOWER(?)'; + $args[] = '%' . $search_term . '%'; + } + } else if ($search_term){ + $sql .= ' WHERE LOWER(a.title) LIKE LOWER(?)'; + $args[] = '%' . $search_term . '%'; + } + + $sql .= ' ORDER BY a.created_at DESC'; + + $articles = $db->exec($sql, $args); + + // render + $f3->set('articles', $articles); + $f3->set('content', '../ui/views/kb/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + + } + + /** + * Form to create new article + */ + public function createForm($f3){ + $this->check_access($f3); + + $db = $f3->get('DB'); + $all_tags = $db->exec('SELECT * FROM tags ORDER BY name ASC'); + $f3->set('all_tags', $all_tags); + + // render + $f3->set('content', '../ui/views/kb/create.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + + } + + // handle POST + public function create($f3){ + $this->check_access($f3); + + $title = $f3->get('POST.title'); + $content = $f3->get('POST.content'); + $created_by = $f3->get('SESSION.user.id'); + + $db = $f3->get('DB'); + + // insert + + $db->exec( + 'INSERT INTO kb (title, content, created_by, updated_by, created_at, updated_at) + VALUES (?,?,?,?, NOW(), NOW())', + [$title, $content, $created_by, $created_by] + ); + + $article_id = $db->lastInsertId(); + + // TODO: tags + + $f3->reroute('/kb'); + } + + // + + protected function check_kb_exists($article_id, $db, $f3){ + $articles = $db->exec( + 'SELECT * FROM kb WHERE id = ? LIMIT 1', [$article_id] + ); + if(!$articles){ + $f3->set('SESSION.error', 'Article not found'); + $f3->reroute('/kb'); + } + return $articles; + } + + // view a single + public function view($f3){ + $this->check_access($f3); + $article_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $articles = $this->check_kb_exists($article_id, $db, $f3); + + $article = $articles[0]; + $f3->set('article', $article); + + // TODO: tags + $tags = $db->exec( + 'SELECT t.* FROM tags AS t + JOIN kb_tags AS at ON t.id = at.tag_id + WHERE at.kb_id = ?', + [$article_id] + ); + + // render + $f3->set('content', '../ui/views/kb/view.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + } + + /** + * Form to edit existing kb article + */ + public function editForm($f3){ + + $this->check_access($f3); + + $article_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $articles = $this->check_kb_exists($article_id, $db, $f3); + + $article = $articles[0]; + $f3->set('article', $article); + + // fetch current tags + $current_tag_ids = $db->exec( + 'SELECT tag_id FROM kb_tags WHERE kb_id = ?', [$article_id] + ); + + $article_tag_ids = array_column($current_tag_ids, 'tag_id'); + $f3->set('article_tag_ids', $article_tag_ids); + + // render + $f3->set('js', 'kb_edit.js'); + $f3->set('content', '../ui/views/kb/edit.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + $f3->clear('SESSION.error'); + + } + + /** + * Handle POST to edit existing article + */ + public function update($f3){ + $this->check_access($f3); + $article_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $articles = $this->check_kb_exists($article_id, $db, $f3); + $article = $articles[0]; + + $title = $f3->get('POST.title'); + $content = $f3->get('POST.content'); + $updated_by = $f3->get('SESSION.user.id'); + + $db->exec( + 'UPDATE kb + SET title=?, content=?, updated_by =?, updated_at = NOW() + WHERE id = ?', + [$title, $content, $updated_by, $article_id] + ); + + // update tags - first delete + $db->exec('DELETE FROM kb_tags WHERE kb_id = ?', [$article_id]); + + $tags_id = $f3->get('POST.tags'); + if(!empty($tags_id) && is_array($tags_id)){ + foreach($tags_id as $tag_id){ + $db->exec( + 'INSERT IGNORE INTO kb_tags (article_id, tag_id) VALUES (?,?)', + [$article_id, $tag_id] + ); + } + } + + $f3->reroute('/kb/'.$article_id); + + } + + + + +} +--- End File: app/controllers/KBController.php --- + + +--- File: app/controllers/ParsedownPreview.php --- +get('POST.content'); + echo Parsedown::instance()->text($preview_text); + + } + +} +--- End File: app/controllers/ParsedownPreview.php --- + + +--- File: app/controllers/ProjectController.php --- +check_access($f3); + + $db = $f3->get('DB'); + + // retrieve projects + $projects = $db->exec('SELECT * FROM projects ORDER BY created_at DESC'); + + $f3->set('projects', $projects); + + + $f3->set('content', '../ui/views/project/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + + $f3->clear('SESSION.error'); + } + + // create a new project + public function createForm($f3){ + $this->check_access($f3); + $f3->set('content', '../ui/views/project/create.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + + } + + public function create($f3){ + + } + + // show project details including links, tickets, events, tasks + public function view($f3){ + $this->check_access($f3); + + $project_id = $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $result = $db->exec( + 'SELECT * FROM projects WHERE id = ? LIMIT 1', [$project_id] + ); + $project = $result[0]; + $f3->set('project', $project); + + $f3->set('content', '../ui/views/project/view.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + // update project details + public function editForm($f3){ + + $this->check_access($f3); + $f3->set('content', '../ui/views/project/edit.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + + } + + public function update($f3){} + + } +--- End File: app/controllers/ProjectController.php --- + + +--- File: app/controllers/TagController.php --- +check_access($f3); + + $db = $f3->get('DB'); + $tags = $db->exec('SELECT * FROM tags ORDER BY name ASC'); + $f3->set('tags', $tags); + + $f3->set('content', '../ui/views/tag/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function createForm($f3){ + $this->check_access($f3); + + $f3->set('content', '../ui/views/tag/create.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function create($f3){ + $this->check_access($f3); + + $name = $f3->get('POST.name'); + $color = $f3->get('POST.color'); + $db = $f3->get('DB'); + + // insert new tag + $db->exec('INSERT IGNORE INTO tags (name, color) VALUES (?, ?)', [$name, $color]); + $f3->reroute('/tags'); + } + + public function view($f3) + { + + } + + public function editForm($f3) + { + + } + + public function update($f3) + { + + } + +} +--- End File: app/controllers/TagController.php --- + + +--- File: app/controllers/ThemeController.php --- +get('SESSION.theme') ?: 'light'; + $new_theme = ($current === 'light') ? 'dark' : 'light'; + $f3->set('SESSION.theme', $new_theme); + + $f3->reroute($f3->get('HEADERS.Referer') ?: '/'); + } +} +--- End File: app/controllers/ThemeController.php --- + + +--- File: app/controllers/TicketController.php --- +requireLogin(); + + $filter = $f3->get('GET.status'); + + // retrieve tickets + $ticket_mapper = new Ticket($this->getDB()); + + if($filter){ + $tickets = $ticket_mapper->findFiltered($filter); + } else { + $tickets = $ticket_mapper->findAll(); + } + + // render + $this->renderView('../ui/views/ticket/index.html', + ['tickets' => $tickets] + ); + + $f3->clear('SESSION.error'); + } + + // view a single ticket + // TODO_PROJECTS: show a link back to the related project + public function view($f3){ + $this->requireLogin(); + + $ticket_id = $f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + // render + $this->renderView('../ui/views/ticket/view.html', [ + 'ticket' => $ticket, + 'attachments' => $ticket->attachments(), + 'comments' => $ticket->comments(), + 'parent_tickets' => $ticket->getParentTickets(), + 'child_tickets' => $ticket->getChildTickets(), + 'ticket_meta' => $ticket->getMetaAssoc() + ]); + + } + + // show create form + // TODO_PROJECTS: dropdown to associate ticket with project + public function createForm($f3){ + $db = $this->getDB(); + $priorities = (new TicketPriority($db))->findAll(); + $statuses = (new TicketStatus($db))->findAll(); + + $this->requireLogin(); + $this->renderView('../ui/views/ticket/create.html',[ + 'priorities' => $priorities, + 'statuses' => $statuses + ]); + } + + // handle POST + // including custom forms + public function create($f3){ + + $this->requireLogin(); + + $data = [ + 'title' => $this->f3->get('POST.title'), + 'created_at' => $this->f3->get('POST.created_at'), + 'description' => $this->f3->get('POST.description'), + 'priority_id' => $this->f3->get('POST.priority_id'), + 'status_id' => $this->f3->get('POST.status_id'), + 'created_by' => $this->f3->get('SESSION.user.id') + ]; + + $ticket_mapper = new Ticket($this->getDB()); + $new_ticket_id = $ticket_mapper->createTicket($data); + + // custom field + $meta_keys = $this->f3->get('POST.meta_key'); + $meta_values = $this->f3->get('POST.meta_value'); + $meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values); + $ticket_mapper->setCustomFields($meta_assoc); + + $this->f3->reroute('/ticket/' . $new_ticket_id); + } + + // show edit form + // including custom forms + // TODO_PROJECTS: allow reasssigning or removing a project association + public function editForm($f3) + { + $this->requireLogin(); + + $ticket_id = $f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Ticket not found.'); + $this->f3->reroute('/tickets'); + } + + // + $f3->set('js', 'markdown_preview.js'); + + // dropdowns + $priorities = (new TicketPriority($this->getDB()))->findAll(); + $statuses = (new TicketStatus($this->getDB()))->findAll(); + + $this->renderView('../ui/views/ticket/edit.html',[ + 'ticket' => $ticket, + 'ticket_meta' => $ticket->getMeta(), + 'priorities' => $priorities, + 'statuses' => $statuses + ] + ); + return; + } + + // process edit POST TODO: if assigned or admin + public function update($f3) + { + + $this->requireLogin(); + + $ticket_id = $this->f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Ticket not found.'); + $this->f3->reroute('/tickets'); + } + + $data = [ + 'title' => $this->f3->get('POST.title'), + 'created_at' => $this->f3->get('POST.created_at'), + 'description' => $this->f3->get('POST.description'), + 'priority_id' => $this->f3->get('POST.priority_id'), + 'status_id' => $this->f3->get('POST.status_id'), + 'updated_by' => $this->f3->get('SESSION.user.id') + ]; + $ticket->updateTicket($data); + + // deal with meta data / custom fields + $meta_keys = $this->f3->get('POST.meta_key'); + $meta_values = $this->f3->get('POST.meta_value'); + $meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values); + $ticket->setCustomFields($meta_assoc); + + $f3->reroute('/ticket/' . $ticket_id); + } + + // subtask + public function addSubtask($f3){ + $this->requireLogin(); + + $parent_id = (int) $f3->get('PARAMS.id'); + $child_id = (int) $f3->get('POST.child_ticket_id'); + + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($parent_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Parent Ticket not found'); + $this->f3->reroute('/tickets'); + } + $ticket->addChildTicket($child_id); + $this->f3->reroute('/ticket/' . $parent_id); + } + + public function delete(): void + { + $this->requireLogin(); + + $ticket_id = (int)$this->f3->get('PARAMS.id'); + $ticket_mapper = new Ticket($this->getDB()); + $ticket = $ticket_mapper->findById($ticket_id); + + if(!$ticket){ + $this->f3->set('SESSION.error', 'Ticket not found'); + $this->f3->reroute('/tickets'); + } + + $ticket->softDelete(); + $this->f3->reroute('/tickets'); + } + +} +--- End File: app/controllers/TicketController.php --- + + +--- File: app/controllers/UserController.php --- +check_access($f3); + + $db = $f3->get('DB'); + $users = $db->exec( + 'SELECT u.*, r.role AS role_name + FROM users u + LEFT JOIN roles r ON r.id = u.role + ORDER BY id ASC' + ); + $f3->set('users', $users); + + $f3->set('content', '../ui/views/user/index.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function editForm($f3){ + $this->check_access($f3); + + $user_id = (int) $f3->get('PARAMS.id'); + $db = $f3->get('DB'); + + $rows = $db->exec( + 'SELECt * FROM users WHERE id = ? LIMIT 1', + [$user_id] + ); + if(!$rows){ + $f3->reroute('/users'); + } + $f3->set('edit_user', $rows[0]); + $f3->set('content', '../ui/views/user/edit.html'); + echo \Template::instance()->render('../ui/templates/layout.html'); + } + + public function update($f3){ + + $this->check_access($f3); + + $user_id = (int) $f3->get('PARAMS.id'); + $new_username = $f3->get('POST.username'); + // $new_role = $f3->get('POST.role_name') + $db = $f3->get('DB'); + $db->exec( + 'UPDATE users SET username = ? WHERE id =? LIMIT 1', + [$new_username, $user_id]); + $f3->reroute('/users'); + } + + public function createForm($f3) + { + + } + + public function create($f3) + { + + } + + public function view($f3) + { + + } + +} +--- End File: app/controllers/UserController.php --- + + +--- File: app/controllers/Admin/HomeController.php --- +renderView('/ui/views/admin/index.html'); + } +} +--- End File: app/controllers/Admin/HomeController.php --- + + +--- File: app/controllers/Admin/TicketOptionsController.php --- +requireLogin(); + $this->requireAdmin(); // Added admin check + + $model = new \TicketPriority($this->getDB()); + $priorities = $model->findAll(); + + $this->renderView('/ui/views/admin/priorities/index.html', [ + 'priorities' => $priorities + ]); + } + + public function createPriorityForm() + { + $this->requireLogin(); + $this->requireAdmin(); // Added admin check + $this->renderView('/ui/views/admin/priorities/create.html'); + } + + public function createPriority() + { + $this->requireLogin(); + $this->requireAdmin(); // Added admin check + $p = new \TicketPriority($this->getDB()); + $p->name = $this->f3->get('POST.name'); + $p->sort_order = $this->f3->get('POST.sort_order'); + $p->save(); + + // Redirect after save + $this->f3->reroute('/admin/priorities'); + } + + public function editPriorityForm($f3, $params) + { + $this->requireLogin(); + $this->requireAdmin(); + $priorityId = $params['id']; + + $model = new \TicketPriority($this->getDB()); + $priority = $model->load(['id = ?', $priorityId]); + + if (!$priority) { + $f3->error(404, 'Priority not found'); + return; + } + + $this->renderView('/ui/views/admin/priorities/edit.html', [ + 'priority' => $priority + ]); + } + + public function updatePriority($f3, $params) + { + $this->requireLogin(); + $this->requireAdmin(); + $priorityId = $params['id']; + + $model = new \TicketPriority($this->getDB()); + $priority = $model->load(['id = ?', $priorityId]); + + if (!$priority) { + $f3->error(404, 'Priority not found'); + return; + } + + $priority->name = $this->f3->get('POST.name'); + $priority->sort_order = $this->f3->get('POST.sort_order'); + $priority->save(); + + // Redirect after update + $this->f3->reroute('/admin/priorities'); + } + + public function deletePriority($f3, $params) + { + $this->requireLogin(); + $this->requireAdmin(); + $priorityId = $params['id']; + + $model = new \TicketPriority($this->getDB()); + $priority = $model->load(['id = ?', $priorityId]); + + if (!$priority) { + // Optionally show an error message or just redirect + $this->f3->reroute('/admin/priorities'); + return; + } + + $priority->erase(); + + // Redirect after delete + $this->f3->reroute('/admin/priorities'); + } + +} +--- End File: app/controllers/Admin/TicketOptionsController.php --- + + +--- File: app/controllers/Admin/UserController.php --- +build($label); + $name = \Template::instance()->build($name); + $value = \Template::instance()->build($value); + $selected = \Template::instance()->build($selected); + + if(defined("BulmaFormHelper::$type")){ + + $type_const = constant("BulmaFormHelper::$type"); + + switch( $type_const ){ + case BulmaFormHelper::H_FIELD_INPUT: + return BulmaFormHelper::build_h_field_input($label, $name, $value); + break; + case BulmaFormHelper::H_FIELD_TEXTAREA: + return BulmaFormHelper::build_h_field_textarea($label, $name, $value); + break; + case BulmaFormHelper::H_FIELD_SELECT: + return BulmaFormHelper::build_h_field_select($label, $name, $options, $selected); + break; + case BulmaFormHelper::H_FIELD_SELECT_NEW: + return BulmaFormHelper::build_h_field_select_new($attr); + break; + case BulmaFormHelper::FIELD_INPUT: + return BulmaFormHelper::build_field_input($label, $name, $value, $class); + break; + case BulmaFormHelper::FIELD_TEXTAREA: + return BulmaFormHelper::build_field_textarea($label, $name, $value, $class, $rows); + break; + case BulmaFormHelper::FIELD_SELECT: + return BulmaFormHelper::build_field_select($attr); + break; + default: + return '
Error: Bulma CSS Form TYPE ('.$type.') not defined.
'; + break; + } + + } else { + return '
Error: Bulma CSS Form TYPE not defined.
'; + } + + } + + static function build_field_input($label, $name, $value, $class, $rows=10){ + + $string_label = $label !== '' ? sprintf('', $label) : ''; + $string = ' +
+ %1$s +
+ +
+
+ '; + return sprintf($string, $string_label, $name, $value, $class, $rows); + } + + static function build_field_textarea($label, $name, $value, $class, $rows=10) + { + $string_label = $label !== '' ? sprintf('', $label) : ''; + $string = ' +
+ %1$s +
+ +
+
+ '; + return sprintf($string, $string_label, $name, $value, $class,$rows); + } + + static function build_h_field_textarea($label, $name, $value){ + $string = ' +
+
+ +
+
+
+
+ +
+
+
+
+ '; + return $string; + } + + static function build_h_field_input($label, $name, $value){ + $string = ' +
+
+ +
+
+
+
+ +
+
+
+
+ '; + return $string; + } + + + /** + * build_field_select_new + * + * `` + * + * @param mixed $attr + * @return void + */ + static function build_field_select($attr) + { + $f3 = \Base::instance(); + + $class = $attr['class'] ?? ''; + $label = $attr['label'] ?? ''; + $name = $attr['name'] ?? ''; + // $options_arr = $attr['options'] ?? []; + $option_value = $attr['option_value'] ?? 'id'; + $option_name = $attr['option_name'] ?? 'name'; + + $options = \Template::instance()->token($attr['options']); + $selected = \Template::instance()->token($attr['selected']); + + // TODO: label - this could be moved into a seperate function + $html_label = $label !== '' ? sprintf('', $label) : ''; + + $tmp_options = 'field_select('. + $options.', '.$selected.', "'.$option_value.'", "'.$option_name.'"); ?>'; + + $html = ' +
+ %1$s +
+
+ +
+
+
+ '; + + return sprintf($html, $html_label, $tmp_options, $name, $class); + } + + function field_select($options, $selected, $option_value, $option_name){ + $html_options = ''; + foreach ($options as $option) { + $value = $option[$option_value] ?? ''; + $text = $option[$option_name] ?? ''; + $html_selected = ((string)$value === (string)$selected) ? ' selected="selected"' : ''; + $html_option = ''; + $html_options .= sprintf($html_option, $value, $html_selected, $text); + } + echo $html_options; + } + + static function build_h_field_select_new($attr) + { + $f3 = \Base::instance(); + + $label = $attr['label'] ?? ''; + $name = $attr['name'] ?? ''; + $options_arr = $attr['options'] ?? []; + $optionValue = $attr['option_value'] ?? 'id'; + $optionName = $attr['option_name'] ?? 'name'; + $selected = $attr['selected'] ?? ''; + + $options = $f3->get($options_arr); + + $html = '
'; + if (!empty($label)) { + $html .= ''; + } + $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= '
'; + + return $html; + } + + + + static function build_h_field_select($label, $name, $options, $selected){ + $opts = json_decode(str_replace("'", '"', $options)); + $opts_string = ""; + foreach($opts as $k => $v){ + if($v == $selected){ + $selected_str = " selected"; + } else { + $selected_str = ""; + } + $opts_string .= ''.$v.''; + } + + $string = + '
+
+ +
+
+
+
+ +
+
+
+
+ '; + return $string; + } + +} + +\Template::instance()->extend('bulma', 'BulmaFormHelper::render'); +--- End File: app/extensions/BulmaFormHelper.php --- + + +--- File: app/extensions/IconsHelper.php --- + ['fas fa-circle-dot has-text-success', "new"], + 'in_progress' => ['fas fa-circle-play has-text-link', "reload"], + 'on_hold' => ['fas fa-pause-circle has-text-warning',"pause"], + 'completed' => ['fas fa-check has-text-danger', "check"] + ]; + + static public $status_names = [ + 'open' => 'Open', + 'in_progress' => 'In Progress', + 'on_hold' => 'On Hold', + 'completed' => 'Completed' + ]; + + static public $priority_icons = [ + 'Low' => ['fas fa-circle-down',"green"], + 'Medium' => ['fas fa-circle-dot', "yellow"], + 'High' => ['fas fa-circle-up', "red"] + ]; + + static public $priority_colors = [ + 'Low' => 'success', + 'Medium' => 'warning', + 'High' => 'danger', + '' => 'info' + ]; + + static public function icons($node){ + + $attr = $node['@attrib']; + + $tpl = Template::instance(); + $f3 = Base::instance(); + + $context = $f3->hive(); + $inner = $tpl->token($node[0], $context); + + return ''; + + } + + static function do_the_switch($type, $value){ + + if($value !== null) { + $value = str_replace(' ', '_', strtolower($value)); + } + $icon_class = ''; + switch(strtolower($type)){ + case 'status': + $icon_class = IconsHelper::$status_icons[$value] ?? ['fas fa-question-circle has-text-info', "🔲"]; + break; + case 'priority': + $icon_class = IconsHelper::$priority_icons[$value] ?? ['fas fa-question-circle', "🔲"]; + $icon_color = IconsHelper::$priority_colors[$value] ?? 'info'; + break; + default: + $icon_class = 'fas fa-question-circle'; + } + + if($type == 'priority'){ + // return '

' + return ' + + '; + } else { + return ''; + } + return ''.$icon_class[1].''; + + } + +} + +\Template::instance()->extend('icons', 'IconsHelper::icons'); +--- End File: app/extensions/IconsHelper.php --- + + +--- File: app/extensions/ParsedownHelper.php --- +text($args[0]); + + return ' +

+ '.$return.' +
+ '; + + } + + // return '
'.print_r($args,1).'
'; + + $content = $args[0]; + $content_token = \Template::instance()->token($content); + + return ' + + build('.$content_token.'); ?> + '; + } + + function build($content){ + return \ParsedownTableExtension::instance()->text($content); + } +} + +\Template::instance()->extend('parsedown', 'ParsedownHelper::render'); +--- End File: app/extensions/ParsedownHelper.php --- + + +--- File: app/extensions/ParsedownTableExtension.php --- + 'table', + // 'handler' => 'elements', + // 'text' => [ ... ], + // 'attributes' => [...], + // ] + + // Add your custom class to the itself: + if (!isset($Block['element']['attributes'])) { + $Block['element']['attributes'] = []; + } + $Block['element']['attributes']['class'] = 'table is-bordered'; + + // Wrap the
in a
: + $wrapped = [ + 'name' => 'div', + 'attributes' => [ + 'class' => 'table-container', + ], + 'handler' => 'elements', + 'text' => [ + $Block['element'], // the
itself + ], + ]; + + // Replace the original element with our wrapped version: + $Block['element'] = $wrapped; + } + + return $Block; + } +} +--- End File: app/extensions/ParsedownTableExtension.php --- + + +--- File: app/interfaces/CRUD.php --- +db->exec( + 'SELECT a.*, u.username + FROM attachments a + LEFT JOIN users u ON u.id = a.uploaded_by + WHERE a.ticket_id = ? + ORDER BY a.created_at DESC', + [$ticket_id] + ); + } + +} +--- End File: app/models/Attachment.php --- + + +--- File: app/models/Comment.php --- +db->exec( + 'SELECT c.*, u.username AS author_name + FROM ticket_comments c + LEFT JOIN users u ON c.created_by = u.id + WHERE c.ticket_id = ? + ORDER BY c.created_at DESC', + [$ticket_id] + ); + } + } +--- End File: app/models/Comment.php --- + + +--- File: app/models/Tag.php --- +tag_table = $type . '_tags'; + $this->tag_table_id = $type . '_id'; + parent::__construct($db, $this->tag_table); + } + return $this; + } + + // VERIFY: possible issue with this? + public function getTagsFor($objects, $id_key = 'id') + { + // echo $this->get('_type_id'); exit; + // printf('
%s
', print_r($this,1)); exit; + if(empty($objects)) return []; + $ids = array_column($objects, $id_key); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $sql = + 'SELECT tt.%1$s, t.id, t.name, t.color + FROM %2$s tt + INNER JOIN tags t ON tt.tag_id = t.id + WHERE tt.%1$s IN (%3$s)'; + $sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table, $placeholders); + $rows = $this->db->exec($sql_sprintf, $ids); + + $tags_map = []; + foreach($rows as $row) + { + $tags_map[$row[$this->tag_table_id]][] = $row; + } + + foreach($objects as &$object) + { + $object['tags'] = $tags_map[$object[$id_key]] ?? []; + } + return $objects; + + } + + public function getTagsForID($id, $id_key = 'id') + { + $sql = 'SELECT tt.%1$s, t.id, t.name, t.color + FROM %2$s tt + INNER JOIN tags t ON tt.tag_id = t.id + WHERE tt.%1$s = ?'; + $sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table); + $rows = $this->db->exec($sql_sprintf, $id); + return $rows; + } + + public function findLinkedTags($id = '') + { + $sql = ' + SELECT t.name, t.color + FROM `?` tt + LEFT JOIN `tags` t ON t.id = tt.id + WHERE tt.`?` = ? + '; + $params = [ + $this->_type, + $this->_type_id, + $id + ]; + return $this->db->exec($sql, $params); + } + + +} +--- End File: app/models/Tag.php --- + + +--- File: app/models/Ticket.php --- +db->exec( + 'SELECT t.id, t.title, t.created_at, + tp.name AS priority_name, ts.name AS status_name, u.display_name + FROM tickets t + LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id + LEFT JOIN ticket_statuses ts ON t.status_id = ts.id + LEFT JOIN users u ON t.created_by = u.id + WHERE t.recycled = 0 + ORDER BY t.created_at DESC' + ); + $result = $this->getTagsForTickets($tickets); + return $result; + } + + public function findFiltered(string $filter): array + { + $sql = ' + SELECT t.*, tp.name AS priority_name, ts.name AS status_name, u.display_name + FROM tickets t + LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id + LEFT JOIN ticket_statuses ts ON t.status_id = ts.id + LEFT JOIN users u ON t.created_by = u.id + WHERE t.recycled = 0 + '; + $params = []; + switch($filter){ + case 'open': + $sql .= ' AND status_id = ?'; + $params[] = 1; + break; + case 'in_progress': + $sql .= ' AND status_id = ?'; + $params[] = 2; + break; + case 'on_hold': + $sql .= ' AND status_id = ?'; + $params[] = 3; + break; + case 'completed': + $sql .= ' AND status_id = ?'; + $params[] = 4; + break; + } + + $sql .= ' ORDER BY t.created_at DESC'; + $tickets = $this->db->exec($sql, $params); + $result = $this->getTagsForTickets($tickets); + return $result; + } + + public function getTagsForTickets(array $tickets) + { + $tag_mapper = new Tag($this->db, 'ticket'); + $tickets = $tag_mapper->getTagsFor($tickets); + + return $tickets; + } + + public function findById($id): ?Ticket + { + $this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id'; + $this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id'; + $this->load(['id = ?', $id]); + $this->tags = (new Tag($this->db,'ticket'))->getTagsForID($id, 'ticket_id'); + return $this->dry() ? null : $this; + } + + public function createTicket(array $data): int + { + $this->reset(); + + $this->title = $data['title'] ?? ''; + $this->description = $data['description'] ?? ''; + // + $this->priority_id = $data['priority_id'] ?? null; + $this->status_id = $data['status_id'] ?? null; + // + $this->created_by = $data['created_by'] ?? null; + $this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s'); + $this->updated_at = date('Y-m-d H:i:s'); + + $this->save(); + return (int)$this->id; + } + + public function updateTicket(array $data): void + { + if(isset($data['title'])){ $this->title = $data['title']; } + if(isset($data['description'])) { $this->description = $data['description']; } + if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; } + if(isset($data['status_id'])) { $this->status_id = $data['status_id']; } + if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; } + $this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s'); + $this->updated_at = date('Y-m-d H:i:s'); + $this->save(); + } + + public function softDelete():void { + $this->recycled = 1; + $this->save(); + } + + public function attachments(){ + $attachment = new Attachment($this->db); + return $attachment->findWithUserByTicketId($this->id); + } + + public function comments(){ + $comment = new Comment($this->db); + return $comment->findWithUserByTicketId($this->id); + } + + public function getParentTickets() + { + return $this->db->exec( + 'SELECT p.* + FROM ticket_relations r + INNER JOIN tickets p ON r.parent_ticket_id = p.id + WHERE r.child_ticket_id = ?', + [$this->id] + ); + } + + public function getChildTickets() + { + return $this->db->exec( + 'SELECT c.* + FROM ticket_relations r + INNER JOIN tickets c ON r.child_ticket_id = c.id + WHERE r.parent_ticket_id = ?', + [$this->id] + ); + } + + public function addChildTicket(int $childId) + { + $this->db->exec( + 'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id) + VALUES (?, ?)', + [$this->id, $childId] + ); + } + + // meta data + public function getMeta() + { + return $this->db->exec( + 'SELECT id, meta_key, meta_value + FROM ticket_meta + WHERE ticket_id = ?', + [$this->id] + ); + } + + public function getMetaAssoc() + { + $rows = $this->getMeta(); + $assoc = []; + foreach($rows as $row){ + $assoc[$row['meta_key']] = $row['meta_value']; + } + return $assoc; + } + + public function assocExistingMeta($meta_ids, $meta_keys, $meta_values){ + if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){ + $field_assoc = []; + foreach($meta_ids as $i => $m_id){ + $key = $meta_keys[$i] ?? ''; + $value = $meta_values[$i] ?? ''; + if(!empty($key) && $value !== ''){ + $field_assoc[$key] = $value; + } + } + return $field_assoc; + } + return []; + } + + public function assocMetaFromKeyValue($meta_keys, $meta_values) + { + if(is_array($meta_keys) && is_array($meta_values)){ + $field_assoc = []; + foreach($meta_keys as $i => $key){ + $val = $meta_values[$i] ?? ''; + if(!empty($key) && $val != ''){ + $field_assoc[$key] = $val; + } + } + return $field_assoc; + } + return []; + } + + public function setCustomFields(array $fields) + { + $this->db->exec( + 'DELETE FROM ticket_meta WHERE ticket_id = ?', [$this->id] + ); + + foreach($fields as $key => $value){ + $this->db->exec( + 'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value) + VALUES (?, ?, ?)', + [$this->id, $key, $value] + ); + } + } + +} +--- End File: app/models/Ticket.php --- + + +--- File: app/models/TicketPriority.php --- +db->exec( + 'SELECT * FROM ticket_priorities ORDER BY sort_order ASC' + ); + } +} +--- End File: app/models/TicketPriority.php --- + + +--- File: app/models/TicketStatus.php --- +db->exec( + 'SELECT * FROM ticket_statuses ORDER BY sort_order ASC' + ); + } +} +--- End File: app/models/TicketStatus.php --- + + +--- File: app/traits/RequiresAuth.php --- +exists('SESSION.user')){ + // $f3->set('SESSION.error', 'You don\'t have permission for this ticket.'); + $f3->set('SESSION.redirect', $f3->get('PATH')); + $f3->reroute('/login'); + } + } + +} +--- End File: app/traits/RequiresAuth.php --- + + +--- File: public/index.php --- +config('../app/config/.env.cfg'); +$f3->set('DEBUG', 3); // development debug +$f3->set('CACHE', FALSE); + +/** + * Not required yet + */ +$htmlpurifier = \HTMLPurifier::instance(); +// $htmlpurifier->purify($input); +$md = \ParsedownTableExtension::instance(); +$md->setSafeMode(true); + +$f3->set('EXT', [new ParsedownHelper, new BulmaFormHelper, new IconsHelper]); + +$f3->set('DB', new \DB\SQL( + 'mysql:host=localhost;port=3306;dbname=' . $f3->get('database.db_name'), + $f3->get('database.username'), + $f3->get('database.password') +)); + +new \DB\SQL\Session($f3->get('DB')); +$f3->set('SESSION.status', 'running'); + +$f3->run(); +--- End File: public/index.php --- + + +--- File: public/logo.svg --- + +--- End File: public/logo.svg --- + + +--- File: public/style.css --- +html, body {padding:0; margin:0;} +html, body, #sidebar, #page,#base_body { + min-height: 100% +} + +#page { min-height: calc(100vh - 170px - 52px) } +i.fa { font-weight: 100 !important ; } + +.table th.th-icon { width: 2rem; } + +#ticket_list .g-flex-item { border-bottom: 1px solid var(--bulma-text-soft); } + +a { word-break: break-word; } + +/* parsedown check-checkbox */ +li.parsedown-task-list { + list-style: none; +} + +/* List Component */ +.list{ + --be-list-color:var(--bulma-text); + --be-list-item-description-color:var(--bulma-text-50); + --be-list-item-divider-color:var(--bulma-border); + --be-list-item-hover-color:var(--bulma-scheme-main-bis); + --be-list-item-image-margin:.75em; + --be-list-item-padding:.75em; + --be-list-item-title-color:var(--bulma-text-strong); + --be-list-item-title-weight:var(--bulma-weight-semibold); + color:var(--be-list-color); + flex-direction:column; + display:flex +} +.list.has-hidden-images .list-item-image{ + display:none +} +.list.has-hoverable-list-items .list-item:hover{ + background-color:var(--be-list-item-hover-color) +} +.list.has-overflow-ellipsis .list-item-content{ + min-inline-size:0; + max-inline-size:calc(var(--length)*1ch) +} +.list.has-overflow-ellipsis .list-item-content>*{ + text-overflow:ellipsis; + white-space:nowrap; + overflow:hidden +} +@media (hover:hover){ + .list:not(.has-visible-pointer-controls) .list-item-controls{ + opacity:0; + visibility:hidden + } +} +.list .list-item{ + align-items:center; + transition:background-color .125s ease-out; + display:flex; + position:relative; + /* TP: update + align top */ + align-items: flex-start; +} +@media (hover:hover){ + .list .list-item:hover .list-item-controls,.list .list-item:focus-within .list-item-controls{ + opacity:initial; + visibility:initial + } +} +.list .list-item:not(.box){ + padding-block:var(--be-list-item-padding); + padding-inline:var(--be-list-item-padding) +} +.list .list-item:not(:last-child):not(.box){ + border-block-end:1px solid var(--be-list-item-divider-color) +} +@media screen and (width<=768px){ + .list:not(.has-overflow-ellipsis) .list .list-item{ + flex-wrap:wrap + } +} +.list .list-item-image{ + flex-shrink:0; + margin-inline-end:var(--be-list-item-image-margin); + /* TP: update + add margin-top */ + margin-top: 0.5rem; +} +@media screen and (width<=768px){ + .list .list-item-image{ + padding-block:.5rem; + padding-inline:0 + } +} +.list .list-item-content{ + flex-direction:column; + flex-grow:1; + display:flex +} +@media screen and (width<=768px){ + .list .list-item-content{ + padding-block:.5rem; + padding-inline:0 + } +} +.list .list-item-title{ + color:var(--be-list-item-title-color); + font-weight:var(--be-list-item-title-weight); + margin-bottom: .25rem; +} +.list .list-item-description{ + color:var(--be-list-item-description-color) +} +.list .list-item-controls{ + flex-shrink:0; + transition:opacity .125s ease-out +} +@media screen and (width<=768px){ + .list .list-item-controls{ + flex-wrap:wrap; + padding-block:.5rem; + padding-inline:0 + } +} +@media screen and (width>=769px),print{ + .list .list-item-controls{ + padding-inline-start:var(--be-list-item-padding) + } + .list:not(.has-visible-pointer-controls) .list .list-item-controls{ + block-size:100%; + align-items:center; + padding-block-end:var(--be-list-item-padding); + display:flex; + position:absolute; + inset-inline-end:0 + } +} +--- End File: public/style.css --- + + +--- File: public/test.md.php --- + + + + + + + + + + + + +

MD Testing

+ +1. MD CONTENT +2. list item two + +and something else + +- and then +- and then +- and then + + +--- End File: public/test.md.php --- + + +--- File: public/js/kb_edit.js --- + + +// switch to target tab pane +function switchTab(targetId){ + var panes = document.querySelectorAll('.tab-content .tab-pane'); + for (var i=0; i< panes.length; i++){ + panes[i].style.display = 'none'; + } + var targetPane = document.getElementById(targetId); + if(targetPane){ + targetPane.style.display = 'block'; + } +} + +// send ajax post request with content to specified url +function ajaxPost(content, url, callback){ + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + xhr.onreadystatechange = function(){ + if(xhr.readyState === XMLHttpRequest.DONE){ + if(xhr.status === 200){ + callback(xhr.responseText); + } else { + console.error("AJAX error: " + xhr.status); + } + } + }; + var params = 'content=' + encodeURIComponent(content); + xhr.send(params); +} + +// load preview via ajax into preview element +function loadPreview(previewElement){ + var sourceId = previewElement.getAttribute('data-source'); + var handlerUrl = previewElement.getAttribute('data-handler'); + var method = previewElement.getAttribute('data-method'); + var sourceElement = document.getElementById(sourceId); + if(sourceElement){ + var content = sourceElement.value; + if(method && method.toLowerCase() == 'post'){ + ajaxPost(content, handlerUrl, function (response){ + previewElement.innerHTML = response; + }); + } + } +} + +// initialise tab links to handle tab switching +function initTabs(){ + var tabLinks = document.querySelectorAll('.tabs a[data-target]'); + for(var i=0; i { + link.addEventListener('click', (e) => this.handleTabClick(e, link)); + }); + } + + async handleTabClick(e, link) { + e.preventDefault(); + const selectedTab = link.getAttribute('data-tab'); + + // Update active tab + this.tabParent.querySelectorAll('li').forEach(li => li.classList.remove('is-active')); + link.parentElement.classList.add('is-active'); + + // Show active content + document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none'); + const activeContent = document.getElementById(`${this.contentPrefix}-${selectedTab}`); + if (activeContent) activeContent.style.display = ''; + + if (selectedTab === 'preview') { + await this.loadPreview(); + } + } + + async loadPreview() { + const previewTarget = document.getElementById('preview-output'); + if (!previewTarget) return; + + previewTarget.innerHTML = ` +
+
+
+ `; + + await new Promise(resolve => setTimeout(resolve, 500)); + + const textarea = document.querySelector(this.textareaSelector); + const markdown = textarea ? textarea.value : ''; + + const res = await fetch(this.previewUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `content=${encodeURIComponent(markdown)}` + }); + + const html = await res.text(); + previewTarget.innerHTML = html; + } + } + + // Usage + document.addEventListener('DOMContentLoaded', () => { + new TabSwitcherController({ + tabSelector: '.tabs', + contentPrefix: 'tab', + textareaSelector: '#description', + previewUrl: '/parsedown/preview' + }); + }); + +--- End File: public/js/markdown_preview.js --- + + +--- File: public/js/ticket_view.js --- +document.addEventListener('DOMContentLoaded', function(){ + const ticket_id = window.location.pathname.split('/')[2]; + const comments_url = `/ticket/${ticket_id}/comments`; + const attachments_url = `/ticket/${ticket_id}/attachments`; + + function ajax(url, containerID){ + fetch(url) + .then(response => { + if(!response.ok){ + throw new Error('Network response was not ok.'); + } + return response.text(); + }) + .then(html => { + const container_el = document.getElementById(containerID); + if(container_el){ + container_el.innerHTML += html; + } else { + throw new Error('Coments container does not exist'); + } + }) + .catch(error => { + console.log('Error fetching comments', error); + }); + } + + ajax(attachments_url, 'attachments') + ajax(comments_url, 'comments') +}); + + +--- End File: public/js/ticket_view.js --- + + +--- File: public/js/tp_md_editor.js --- +/** + * tp_md_editor.js + * Self-contained Markdown Editor with Toolbar Buttons + * Usage: tp_md_editor.init(config) + */ + +class TPMarkdownEditor { + static init(config = {}) { + document.querySelectorAll('tp-md-editor').forEach(editor => { + new TPMarkdownEditor(editor, config); + }); + } + + constructor(wrapper, config) { + this.wrapper = wrapper; + this.name = wrapper.getAttribute('name'); + this.config = config; + this.textarea = document.createElement('textarea'); + this.textarea.name = this.name; + this.textarea.rows = 25; + this.toolbar = document.createElement('tp-md-toolbar'); + + this.undoStack = [] + this.redoStack = [] + this.localStorageKey = this.getStorageKey(); + this.unsaved = false; + this.autosaveInterval = null; + + // this.loadInitialContent(); + this.captureInitialContent(); + this.createUnsavedBanner(); + this.loadFromLocalStorage(); + + this.wrapper.appendChild(this.unsavedBanner); + this.wrapper.appendChild(this.toolbar); + this.wrapper.appendChild(this.textarea); + + this.buttonClasses = TPMarkdownEditor.defaultButtons(); + this.buildToolbar(); + this.setupAutoList(); + this.setupUndoRedo(); + this.setupPersistence(); + this.setupAutoSave(); + } + + getStorageKey(){ + const path = window.location.pathname; + return `tp_md_editor:${path}:${this.name}`; + } + + createUnsavedBanner(){ + const container = document.createElement('div'); + container.style.cssText = 'background: #fff3cd; color: #856404; padding: 5px 10px; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center; display: none;'; + + const text = document.createElement('span'); + text.textContent = 'You have unsaved changes.'; + + const discardBtn = document.createElement('button'); + discardBtn.textContent = 'Discard'; + discardBtn.style.cssText = 'margin-left: auto; background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer;'; + discardBtn.addEventListener('click', () => { + localStorage.removeItem(this.localStorageKey); + const hidden = this.wrapper.querySelector('.tp-md-initial'); + if(hidden){ + this.textarea.value = hidden.textContent; + } + this.clearUnsaved(); + }); + + container.appendChild(text); + container.appendChild(discardBtn); + this.unsavedBanner = container + } + + markUnsaved(){ + this.unsaved = true; + this.unsavedBanner.style.display = 'block'; + } + + clearUnsaved(){ + this.unsaved = false; + this.unsavedBanner.style.display = 'none'; + } + + setupPersistence(){ + window.addEventListener('beforeunload', (e) => { + if(this.unsaved){ + localStorage.setItem(this.localStorageKey, this.textarea.value); + } + }); + } + + setupAutoSave(){ + this.autosaveInterval = setInterval(() => { + if(this.unsaved){ + localStorage.setItem(this.localStorageKey, this.textarea.value); + } + }, 5000); // save every 5 sec + } + + loadFromLocalStorage(){ + const saved = localStorage.getItem(this.localStorageKey); + if(saved){ + this.textarea.value = saved; + this.markUnsaved(); + } + } + + buildToolbar() { + const groups = this.config.groups || [Object.keys(this.buttonClasses)]; + groups.forEach(group => { + const groupEl = document.createElement('tp-md-toolbar-group'); + group.forEach(btnType => { + const BtnClass = this.buttonClasses[btnType]; + if (BtnClass) { + const btn = new BtnClass(this.textarea); + groupEl.appendChild(btn.element); + } + }); + this.toolbar.appendChild(groupEl); + }); + } + + setupUndoRedo(){ + this.textarea.addEventListener('input', ()=>{ + this.undoStack.push(this.textarea.value); + if(this.undoStack > 100) this.undoStack.shift(); + this.redoStack = []; + this.markUnsaved(); + }); + + this.textarea.addEventListener('keydown', (e) => { + if(e.ctrlKey && e.key === 'z'){ + e.preventDefault(); + if(this.undoStack.length > 0 ){ + this.redoStack.push(this.textarea.value); + this.textarea.value = this.undoStack.pop(); + this.markUnsaved(); + } + } else if (e.ctrlKey && (e.key === 'y')) { // || e.shiftkey && e.key === 'z' + e.preventDefault(); + if(this.redoStack.length > 0){ + this.undoStack.push(this.textarea.value); + this.textarea.value = this.redoStack.pop(); + this.markUnsaved(); + } + } + }); + } + + setupAutoList() { + this.textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const pos = this.textarea.selectionStart; + const before = this.textarea.value.slice(0, pos); + const after = this.textarea.value.slice(pos); + const lines = before.split('\n'); + const lastLine = lines[lines.length - 1]; + + // need order of task > ul + let match; + if ((match = lastLine.match(/^(\s*)- \[( |x)\] /))) { + // task + e.preventDefault(); + if (lastLine.trim() === '- [ ]' || lastLine.trim() === '- [x]') { + this.removeLastLine(pos, lastLine.length); + } else { + const insert = '\n' + match[1] + '- [ ] '; + this.insertAtCursor(insert); + } + } else if ((match = lastLine.match(/^(\s*)([-*+] )/))) { + // ul + e.preventDefault(); + if (lastLine.trim() === match[2].trim()) { + this.removeLastLine(pos, lastLine.length); + } else { + const insert = '\n' + match[1] + match[2]; + this.insertAtCursor(insert); + } + } else if ((match = lastLine.match(/^(\s*)(\d+)\. /))) { + // ol + e.preventDefault(); + if (lastLine.trim() === `${match[2]}.`) { + this.removeLastLine(pos, lastLine.length); + } else { + const nextNum = parseInt(match[2]) + 1; + const insert = `\n${match[1]}${nextNum}. `; + this.insertAtCursor(insert); + } + } + } + }); + } + + removeLastLine(cursorPos, lengthToRemove) { + const start = cursorPos - lengthToRemove; + this.textarea.setRangeText('', start, cursorPos, 'start'); + this.textarea.setSelectionRange(start, start); + } + + insertAtCursor(text) { + const start = this.textarea.selectionStart; + const end = this.textarea.selectionEnd; + this.textarea.setRangeText(text, start, end, 'end'); + const newPos = start + text.length; + this.textarea.setSelectionRange(newPos, newPos) + this.markUnsaved(); + } + + captureInitialContent() { + const hidden = document.createElement('script'); + hidden.type = 'text/plain'; + // hidden.style.display = 'none'; + hidden.classList.add('tp-md-initial'); + hidden.textContent = this.wrapper.textContent.trim(); + // clear inner content so it's not visible twice + this.wrapper.textContent = ''; + + this.wrapper.appendChild(hidden); + this.textarea.value = hidden.textContent; + } + + static defaultButtons() { + return { + h1: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '# ', 'H1', 'fas fa-heading', '', 'fas fa-1'); } + }, + h2: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '## ', 'H2', 'fas fa-heading', '', 'fas fa-2'); } + }, + h3: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '### ', 'H3', 'fas fa-heading', '', 'fas fa-3'); } + }, + bold: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '**', 'Bold', 'fas fa-bold', '**'); } + }, + italic: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '_', 'Italic', 'fas fa-italic', '_'); } + }, + quote: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '> ', 'Quote', 'fas fa-quote-right'); } + }, + code: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '`', 'Code', 'fas fa-code', '`'); } + formatSelection(sel) { + if (!sel || !sel.includes('\n')) { + return '`' + (sel || 'code') + '`'; + } + return '```\n' + sel + '\n```'; + } + }, + link: class extends TPMarkdownButton { + constructor(textarea) { + super(textarea, '[', 'Link', 'fas fa-link', '](url)'); + } + formatSelection(sel) { + return `[${sel || 'text'}](url)`; + } + }, + bullet: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '- ', 'Bullet', 'fas fa-list-ul'); } + formatSelection(sel){ + return sel.split('\n').map(line => '- ' + line).join('\n'); + } + }, + number: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '1. ', 'Numbered', 'fas fa-list-ol'); } + formatSelection(sel){ + return sel.split('\n').map((line, i) => `${i+1}. ${line}`).join('\n'); + } + }, + task: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '- [ ] ', 'Task', 'fas fa-tasks'); } + formatSelection(sel){ + return sel.split('\n').map(line => ' - [ ]' + line).join('\n') + } + }, + hr: class extends TPMarkdownButton { + constructor(textarea) { super(textarea, '---\n', 'HR', 'fas fa-minus'); } + }, + table: class extends TPMarkdownButton { + constructor(textarea) { + super(textarea, '', 'Table', 'fas fa-table'); + } + formatSelection(_) { + return '| Col1 | Col2 |\n|------|------|\n| Val1 | Val2 |'; + } + }, + }; + } +} + +class TPMarkdownButton { + constructor(textarea, prefix = '', title = '', icon = '', suffix = '', icon_offset = '') { + this.textarea = textarea; + this.prefix = prefix; + this.suffix = suffix; + this.element = document.createElement('tp-md-toolbar-button'); + this.element.title = title; + if (icon_offset == '') { + this.element.innerHTML = ``; + } else { + this.element.innerHTML = ``; + } + this.element.addEventListener('click', () => this.apply()); + } + + formatSelection(sel) { + return this.prefix + (sel || 'text') + this.suffix; + } + + apply() { + const textarea = this.textarea; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + this.previousValue = textarea.value; + const selected = text.substring(start, end); + const formatted = this.formatSelection(selected); + textarea.setRangeText(formatted, start, end, 'end'); + + if(this.previousValue !== textarea.value){ + if(!textarea.undoStack) textarea.undoStack = []; + textarea.undoStack.push(this.previousValue); + } + + textarea.focus(); + } +} + +// Export as global +window.tp_md_editor = TPMarkdownEditor; +--- End File: public/js/tp_md_editor.js --- + + +--- File: scss/main.scss --- +// import bulma +@use "vendor/bulma"; + +// import custom components +@use "components/ticket-item"; +--- End File: scss/main.scss --- + + +--- File: scss/components/_ticket-item.scss --- +@use "../vendor/bulma"; + +.ticket-item { + @extend .is-flex; + @extend .mb-1; + @extend .pt-1; + @extend .pb-2; + @extend .is-align-items-flex-start; + border-bottom: 1px solid var(--bulma-text-90); + + .ticket-icon { + @extend .mr-2; + display: flex; + align-items: baseline; + + .checkbox { + margin-right: 0.5rem; + } + } + + .ticket-content { + @extend .is-flex; + @extend .is-flex-direction-column; + @extend .is-flex-grow-1; + align-self: baseline; + + .ticket-header { + @extend .is-flex; + @extend .is-justify-content-flex-start; + @extend .is-flex-wrap-wrap; + @extend .mb-1; + align-items: center; + + .ticket-title { + @extend .title; + @extend .mb-0; + @extend .is-5; + font-weight: normal; + } + + .tags { + @extend .ml-2; + } + } + + .ticket-meta { + @extend .is-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + + p { + @extend .subtitle; + @extend .is-6; + font-weight: 300; + margin: 0; + } + } + } +} +--- End File: scss/components/_ticket-item.scss --- + + +--- File: scss/vendor/_bulma-tools.scss --- +@use "../../node_modules/bulma/sass/utilities/" as bulma-utils; +@use "../../node_modules/bulma/sass/helpers/" as bulma-helpers; +@use "../../node_modules/bulma/sass/elements/" as bulma-elements; +--- End File: scss/vendor/_bulma-tools.scss --- + + +--- File: scss/vendor/_bulma.scss --- +@forward "../../node_modules/bulma/bulma"; +--- End File: scss/vendor/_bulma.scss --- + + +--- File: ui/partials/ticket_item.html --- +
+
+
+ +
+ + {{@ticket.status_name}} +
+
+
+ + {{ @ticket.title }} + +
+ + {{ @tag.name }} + +
+
+
+

#{{ @ticket.id }} opened {{ @ticket.created_at }} by {{ @ticket.display_name }}

+
+
+
+--- End File: ui/partials/ticket_item.html --- + + +--- File: ui/parts/clipboard.html --- +
+ + +
+ Paste or drag an image here +
+ +

+ + +
+--- End File: ui/parts/clipboard.html --- + + +--- File: ui/session/error.html --- + +
+ {{ @SESSION.error }} +
+
+--- End File: ui/session/error.html --- + + +--- File: ui/templates/layout.html --- + + + + + + + TP ServiceDesk + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + +
+
+ + +
+
+

© + Terry Probert +

+
+
+ + + + + + +--- End File: ui/templates/layout.html --- + + +--- File: ui/views/dashboard.html --- +

Dashboard

+--- End File: ui/views/dashboard.html --- + + +--- File: ui/views/home.html --- + + +
+
+
+

TP ServiceDesk

+

One place to manage requests, store knowledge, and collaborate on projects

+

+ + + Get Started + + + + Browse Knowledge Base + +

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

Ticketing System

+
    +
  • Create & Track tickets
  • +
  • Assign priorities & statuses
  • +
  • Link child/parent tickets
  • +
+
+
+
+ + +
+
+
+

Knowledge Base

+
    +
  • Markdown-powered articles
  • +
  • Tagging and filtering
  • +
  • Fast searching
  • +
+
+
+
+ + +
+
+
+

Projects

+
    +
  • Track ongoing projects
  • +
  • Integreate tasks and tickets
  • +
  • Monitor progress
  • +
+
+
+
+ + +
+
+
+

Collaboration

+
    +
  • Comment threads
  • +
  • File attachments
  • +
  • Role-based user access
  • +
+
+
+
+ + +
+
+
+

Custom fields

+
    +
  • Define ticket meta data
  • +
  • Configure and store extra info
  • +
  • Easily editable in forms
  • +
+
+
+
+ + +
+
+
+

Administration

+
    +
  • Manage user roles
  • +
  • Create new account
  • +
  • Edit existing users
  • +
+
+
+
+ + + +
+
+
+--- End File: ui/views/home.html --- + + +--- File: ui/views/login.html --- +

Please Log In

+ + +
+

{{ @error }}

+
+
+ +
+ +
+

+ + + + +

+
+
+

+ + + + +

+
+
+

+ +

+
+ + +--- End File: ui/views/login.html --- + + +--- File: ui/views/admin/index.html --- +

Admin

+
+ +
+

Ticket > Priorities

+

Ticket > Statuses

+
+--- End File: ui/views/admin/index.html --- + + +--- File: ui/views/admin/priorities/create.html --- +

Create Ticket Priority

+

TODO:

+--- End File: ui/views/admin/priorities/create.html --- + + +--- File: ui/views/admin/priorities/index.html --- +

Admin: Ticket Priorities

+

create priority

+
+ +
+ + + + + + + + + + + + + + + + + + + +
idnamesort_order
{{@priority.id}}{{@priority.name}}{{@priority.sort_order}} + + + + +
+--- End File: ui/views/admin/priorities/index.html --- + + +--- File: ui/views/attachment/index.html --- +
+
+

Attachments

+
+ + + + + + + + + + + + + + + + + + + + + + + +
File NameUploaded ByCreated AtVersion
+ + {{ @attach.file_name }}{{ @attach.username }}{{ @attach.created_at }}{{ @attach.version_number }}
+
+
+ + + +
+
+
+
+
+
+
+ + + + +
+
+ +
+
+
+
+
+
+
+--- End File: ui/views/attachment/index.html --- + + +--- File: ui/views/comments/view.html --- +
+
+

Comments

+ +
+ +
+
+
+ + +
+
+
+
+ {{ @comment.author_name}} + {{ @comment.created_at }} +
+
+ {{ @comment.comment | raw }} +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+--- End File: ui/views/comments/view.html --- + + +--- File: ui/views/kb/create.html --- +

Create Knowledge Base Article

+ + +
+
+ + + +
+
+ +
+ +
+
+ +
+
+ +
+
+
+ + + + +
+
+ Cancel +
+
+ +
+
+
+
+--- End File: ui/views/kb/create.html --- + + +--- File: ui/views/kb/edit.html --- +

Edit Knowledge Base Article

+ + +
+ + + +
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ + + + + +
+--- End File: ui/views/kb/edit.html --- + + +--- File: ui/views/kb/index.html --- +

Knowledge Base

+

create kb article

+
+ +
+
+
+
+ + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
idtitlecreated_at
{{@article.id}}{{@article.title}}{{@article.created_at}} + +
+
+ + +
+

No articles found.

+
+
+--- End File: ui/views/kb/index.html --- + + +--- File: ui/views/kb/view.html --- +

{{@article.title}}

+

edit article

+
+ +
+ {{ @article.content | raw }} +
+ + + + +
+ + + + + + + + + +
PropertyValue
{{@key}} {{@value}}
+
+
+ +--- End File: ui/views/kb/view.html --- + + +--- File: ui/views/project/create.html --- +
+    TODO: create form.
+
+--- End File: ui/views/project/create.html --- + + +--- File: ui/views/project/edit.html --- +
+    TODO: edit form
+
+--- End File: ui/views/project/edit.html --- + + +--- File: ui/views/project/index.html --- +

Projects

+

create project

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleRequesterCreated ByCreated AtStart DateEnd Date
{{ @p.id }}{{ @p.title }}{{ @p.requester }}{{ @p.created_by }}{{ @p.created_at }}{{ @p.start_date }}{{ @p.end_date }}
+ +--- End File: ui/views/project/index.html --- + + +--- File: ui/views/project/view.html --- +

{{ @project.title }}

+

edit project

+
+
+
+

Overview

+
+ {{ @project.description }} +
+
+
+
+

Links

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

Tickets

+
+
+
+
+
+
+
+
+
+

Tasks

+
+
+
+
+
+
+
+
+
+

Events

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

Timeline

+
+
+
+
+
+
+
+
+
+ + +--- +## View project + +A central place to see everything for this project: + +- Overview: (title, description, links, start/end dates). +- related tickets (with status and priorities) +- events +- tasks +- timeline combining events, tickets, milestone dates + +## Example Workflow + +- create a project - `team manager overview` +-- attach relevant links +- add tickets - each new request or issue can be a ticket referencing this project +- add events - quick notes about management meetings, or verbal discussions that don't need ticket overhead +-- meeting on 01 jan to discuss layout +-- teams message on 28 jan clarifying data requirements +- project tasks - for smaller to do items that don't warrant a full ticket +-- identify location of required data, create initial pq connections, build a mockup layout + +## Reporting Timelines +- timeline view - merge ticket data with project_events sorted by date - chronological Overview +- status summaries - how many tickets open, on hold, completed +- progress tracking - sumarries or gantt style charts + +--- End File: ui/views/project/view.html --- + + +--- File: ui/views/tag/create.html --- +

Create Tag

+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ Cancel +
+
+ +
+
+
+
+--- End File: ui/views/tag/create.html --- + + +--- File: ui/views/tag/index.html --- +

Tags

+

create tag

+
+ + + +
+ + {{ @tag.name }} + +
+
+ +
+ No tags found +
+
+
+ + +
+
+

Color Examples

+

The following color names can be used for tags

+
+
+ Black + Dark + Light + White + Primary + Link + Info + Success + Warning + Danger +
+
+--- End File: ui/views/tag/index.html --- + + +--- File: ui/views/ticket/create.html --- +

Create Ticket Form

+ +
+ + + + + + + + + + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ Cancel +
+
+ +
+
+ +
+--- End File: ui/views/ticket/create.html --- + + +--- File: ui/views/ticket/edit.html --- + + +
+
+
+ +
+
+ +
+ Cancel +
+
+ +
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+ +
+
+ + + + +
+ +
+ + + + + + + + + + + +
PropertyValue
{{@key}} {{@value}}
+
+ +
+

Linked Tickets

+ + +
+

Parent Tickets

+ +
+ + + +
+

Child Tickets

+
+
+ + +
+ +
+ +
+
+
+
+ +
+
+ + */ ?> +
+
+
+
+ +
+ +
+ + + + + + + + + +
+ +--- End File: ui/views/ticket/edit.html --- + + +--- File: ui/views/ticket/edit.html.v1 --- +

Edit Ticket Form

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

Custom Fields

+ +
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+--- End File: ui/views/ticket/edit.html.v1 --- + + +--- File: ui/views/ticket/index.html --- +

Tickets

+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ + + + + + +*/ +?> +--- End File: ui/views/ticket/index.html --- + + +--- File: ui/views/ticket/index_row.html --- +
+
+ + {{@ticket.status_name}} +
+
+
+ +
+ + tag +
+
+
+

#{{@ticket.id}} opened 2025-03-25 by {{@ticket.display_name}}

+
+
+
+--- End File: ui/views/ticket/index_row.html --- + + +--- File: ui/views/ticket/view.html --- + +
+

{{ @ticket.title }}

+ +
+ +
+ +
+ +
+
+
+

{{ @ticket.created_at }}

+
+ {{ @ticket.description | raw }} +
+
+
+ +
+ +
+
+ + {{@tag.name}} + +
+ + + + + + + + + + + +
PropertyValue
{{@key}} {{@value}}
+
+ +
+

Linked Tickets

+ + +
+

Parent Tickets

+ +
+
+ + +
+

Child Tickets

+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+ + + +
+ + + + + + +
+--- End File: ui/views/ticket/view.html --- + + +--- File: ui/views/user/edit.html --- + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+--- End File: ui/views/user/edit.html --- + + +--- File: ui/views/user/index.html --- +
+

All Users

+ + + + + + + + + + + + + + +
IDUsernameRoleActions
{{ @u.id }}{{ @u.username }}{{ @u.role_name }} ( {{ @u.role }} )
+
+--- End File: ui/views/user/index.html --- + +============================================================ +End of Codebase +============================================================ \ No newline at end of file diff --git a/_codebase_schemafile.sql b/_codebase_schemafile.sql new file mode 100644 index 0000000..c72ac55 --- /dev/null +++ b/_codebase_schemafile.sql @@ -0,0 +1,362 @@ +-- MariaDB dump 10.19 Distrib 10.4.32-MariaDB, for Win64 (AMD64) +-- +-- Host: localhost Database: tp_servicedesk +-- ------------------------------------------------------ +-- Server version 10.4.32-MariaDB + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `attachments` +-- + +DROP TABLE IF EXISTS `attachments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `attachments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `path` text NOT NULL, + `file_name` text NOT NULL, + `ticket_id` int(11) DEFAULT NULL, + `kb_id` int(11) DEFAULT NULL, + `version_number` int(11) NOT NULL, + `uploaded_by` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb` +-- + +DROP TABLE IF EXISTS `kb`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `kb` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` text NOT NULL, + `content` text NOT NULL, + `created_by` int(11) NOT NULL, + `updated_by` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb_tags` +-- + +DROP TABLE IF EXISTS `kb_tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `kb_tags` ( + `kb_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + `id` int(11) NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `project_events` +-- + +DROP TABLE IF EXISTS `project_events`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `project_events` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `event_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `description` text NOT NULL, + `created_by` int(11) NOT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `project_links` +-- + +DROP TABLE IF EXISTS `project_links`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `project_links` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `url` text NOT NULL, + `description` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `project_tasks` +-- + +DROP TABLE IF EXISTS `project_tasks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `project_tasks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `title` varchar(255) NOT NULL, + `description` text NOT NULL, + `status` enum('pending','in_progress','done','') NOT NULL, + `start_date` timestamp NULL DEFAULT NULL, + `end_date` timestamp NULL DEFAULT NULL, + `created_by` int(11) NOT NULL, + `created_at` int(11) NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `projects` +-- + +DROP TABLE IF EXISTS `projects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `projects` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `description` text NOT NULL, + `requester` varchar(255) NOT NULL, + `created_by` int(11) NOT NULL, + `start_date` timestamp NULL DEFAULT NULL, + `end_date` timestamp NULL DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `roles` +-- + +DROP TABLE IF EXISTS `roles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `roles` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `role` varchar(50) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `sessions` +-- + +DROP TABLE IF EXISTS `sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `sessions` ( + `session_id` varchar(255) NOT NULL, + `data` text DEFAULT NULL, + `ip` varchar(45) DEFAULT NULL, + `agent` varchar(300) DEFAULT NULL, + `stamp` int(11) DEFAULT NULL, + PRIMARY KEY (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tags` +-- + +DROP TABLE IF EXISTS `tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` text NOT NULL, + `color` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_comments` +-- + +DROP TABLE IF EXISTS `ticket_comments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_comments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `comment` text NOT NULL, + `created_by` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `deleted` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `ticket_id` (`ticket_id`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_meta` +-- + +DROP TABLE IF EXISTS `ticket_meta`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_meta` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `meta_key` text NOT NULL, + `meta_value` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_priorities` +-- + +DROP TABLE IF EXISTS `ticket_priorities`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_priorities` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `sort_order` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_relations` +-- + +DROP TABLE IF EXISTS `ticket_relations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_relations` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `parent_ticket_id` int(11) NOT NULL, + `child_ticket_id` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_statuses` +-- + +DROP TABLE IF EXISTS `ticket_statuses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_statuses` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `sort_order` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_tags` +-- + +DROP TABLE IF EXISTS `ticket_tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_tags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ticket_updates` +-- + +DROP TABLE IF EXISTS `ticket_updates`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `ticket_updates` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` int(11) NOT NULL, + `comment` text NOT NULL, + `updated_by` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tickets` +-- + +DROP TABLE IF EXISTS `tickets`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tickets` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` text NOT NULL, + `description` text NOT NULL, + `status_id` int(11) NOT NULL, + `priority_id` int(11) NOT NULL, + `created_by` int(11) NOT NULL, + `assigned_to` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT NULL, + `updated_by` int(11) DEFAULT NULL, + `project_id` int(11) DEFAULT NULL, + `recycled` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` text NOT NULL, + `password` text NOT NULL, + `email` text NOT NULL, + `display_name` text NOT NULL, + `role` int(11) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `is_admin` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) USING HASH +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-04-30 0:35:57 diff --git a/codebase-to-text.py b/codebase-to-text.py new file mode 100644 index 0000000..b6045df --- /dev/null +++ b/codebase-to-text.py @@ -0,0 +1,487 @@ +import os +import argparse +import subprocess +import git # Still needed for potential future use or for checking if it's a repo, even if not cloning +import shutil +import tempfile +from pathlib import Path +from docx import Document +from pathspec import PathSpec +from pathspec.patterns import GitWildMatchPattern # Explicit import often good practice + +class CodebaseToText: + def __init__(self, input_path, output_path, output_type, verbose, exclude_hidden, ignored_paths=None): + # Normalize input path early + self.input_path = os.path.abspath(input_path) # Use absolute path for consistency + self.output_path = output_path + self.output_type = output_type + self.verbose = verbose + self.exclude_hidden = exclude_hidden + self.ignored_paths = ignored_paths if ignored_paths else [] # Store custom ignores + + self.temp_folder_path = None # Used only if cloning + self.is_cloned_repo = False # Flag to track if we cloned + + self.git_ignore_spec = None + self.custom_ignore_spec = None + + self._initialize_ignores() # Load ignores after setting input_path + + def _initialize_ignores(self): + """Loads .gitignore and initializes custom ignore spec.""" + # Load .gitignore relative to the current input_path + gitignore_path = os.path.join(self.input_path, ".gitignore") + if os.path.exists(gitignore_path): + try: + with open(gitignore_path, 'r', encoding='utf-8') as f: # Specify encoding + lines = f.read().splitlines() + # Filter out empty lines and comments + lines = [line for line in lines if line.strip() and not line.strip().startswith('#')] + if lines: + self.git_ignore_spec = PathSpec.from_lines(GitWildMatchPattern, lines) + if self.verbose: + print(f"Loaded .gitignore rules from: {gitignore_path}") + except Exception as e: + print(f"Warning: Could not read .gitignore file at {gitignore_path}: {e}") + elif self.verbose: + print(f"No .gitignore file found at: {gitignore_path}") + + # Create PathSpec for custom ignored paths + if self.ignored_paths: + # Filter out empty lines/patterns just in case + valid_custom_paths = [p for p in self.ignored_paths if p.strip()] + if valid_custom_paths: + self.custom_ignore_spec = PathSpec.from_lines(GitWildMatchPattern, valid_custom_paths) + if self.verbose: + print(f"Using custom ignore rules: {valid_custom_paths}") + else: + self.ignored_paths = [] # Clear if only contained empty strings + + + def _is_path_ignored(self, file_or_dir_path): + """Checks if a given path should be ignored based on all rules.""" + try: + # Calculate relative path from the project root (self.input_path) + # Use pathlib for robustness + base_path = Path(self.input_path) + target_path = Path(file_or_dir_path) + # Use absolute paths temporarily to ensure correct relative calculation + rel_path = target_path.relative_to(base_path).as_posix() # Use POSIX paths for pathspec + except ValueError: + # If the path is not relative to input_path (shouldn't normally happen with os.walk) + if self.verbose: + print(f"Warning: Path {file_or_dir_path} is not relative to {self.input_path}. Skipping ignore checks for it.") + return False # Or decide how to handle this case + + # Check .gitignore rules + if self.git_ignore_spec and self.git_ignore_spec.match_file(rel_path): + if self.verbose > 1: # More detailed verbose logging if needed + print(f"Ignoring '{rel_path}' (gitignore)") + return True + + # Check custom ignore rules + if self.custom_ignore_spec and self.custom_ignore_spec.match_file(rel_path): + if self.verbose > 1: + print(f"Ignoring '{rel_path}' (custom)") + return True + + # Check if hidden files/dirs should be excluded + # Note: PathSpec patterns can also match hidden files (e.g., '.*'), + # so this check is primarily for the simple dot/underscore prefix rule. + if self.exclude_hidden and self._is_hidden_path_component(target_path): + if self.verbose > 1: + print(f"Ignoring '{rel_path}' (hidden)") + return True + + return False + + def _is_hidden_path_component(self, path_obj: Path): + """Checks if any component of the path starts with '.' or '__'.""" + # Check the name itself and its parents relative to the base input path + relative_parts = path_obj.relative_to(self.input_path).parts + return any(part.startswith(('.', '__')) for part in relative_parts if part != '.') + + + def _parse_folder(self, folder_path): + """Generates the directory tree string, respecting ignore rules.""" + tree = "" + base_level = folder_path.count(os.sep) + + # Ensure folder_path is absolute for consistent relative path calculations + abs_folder_path = Path(folder_path).resolve() + + for root, dirs, files in os.walk(abs_folder_path, topdown=True): + abs_root_path = Path(root).resolve() + + # --- Directory Ignore Logic --- + # Filter directories *before* recursing into them + # Keep track of original dirs list to modify dirs[:] + original_dirs = list(dirs) + dirs[:] = [] # Clear dirs list, we will re-add ones we want to keep + + for d in original_dirs: + dir_path = abs_root_path / d + # Skip .git directory explicitly (essential) + if d == ".git": + if self.verbose > 1: print(f"Skipping .git directory: {dir_path}") + continue + + if self._is_path_ignored(str(dir_path)): + if self.verbose: + print(f"Ignoring directory: {dir_path.relative_to(self.input_path)}") + # Don't add 'd' back to dirs[:], effectively pruning the walk + else: + dirs.append(d) # Keep this directory for recursion + + # --- Calculate Tree Indentation --- + try: + # Calculate level relative to the *initial* input path for correct indentation + rel_root = abs_root_path.relative_to(self.input_path) + level = len(rel_root.parts) if rel_root.parts != ('.',) else 0 + except ValueError: + # Should not happen if os.walk starts within input_path + print(f"Warning: Cannot determine relative path for {abs_root_path}. Using level 0.") + level = 0 + + indent = ' ' * level # 4 spaces per level + # Add directory entry to tree (only if it's not the root itself processed initially) + if abs_root_path != Path(self.input_path).resolve(): # Don't print root '/' + tree += f"{indent}{abs_root_path.name}/\n" + elif level == 0 and not tree: # Print root marker only once at the start + tree += f"{Path(self.input_path).name}/\n" + + + # --- File Listing --- + subindent = ' ' * (level + 1) + sorted_files = sorted(files) # Sort files for consistent output + + for f in sorted_files: + file_path = abs_root_path / f + # Check if file is ignored + if not self._is_path_ignored(str(file_path)): + tree += f"{subindent}{f}\n" + elif self.verbose: + # Note: _is_path_ignored already prints detailed reasons if verbose > 1 + print(f"Ignoring file (in tree): {file_path.relative_to(self.input_path)}") + + + if self.verbose: + print(f"\n--- Generated File Tree ---\n{tree}") + print("--- End File Tree ---\n") + + return tree + + def _get_file_contents(self, file_path): + """Reads file content, handling potential encoding issues.""" + try: + with open(file_path, 'r', encoding='utf-8') as file: + return file.read() + except UnicodeDecodeError: + try: + # Try a fallback encoding (e.g., latin-1 or detected encoding) + with open(file_path, 'r', encoding='latin-1') as file: + if self.verbose: print(f"Warning: Used fallback encoding 'latin-1' for {file_path}") + return file.read() + except Exception as e: + print(f"Error: Could not read file {file_path} with utf-8 or latin-1: {e}") + return f"Error reading file: {e}" # Include error message in output + except Exception as e: + print(f"Error reading file {file_path}: {e}") + return f"Error reading file: {e}" + + + def _process_files(self, path_to_walk): + """Walks through files and concatenates their content, respecting ignores.""" + content = "" + base_path = Path(self.input_path).resolve() # Use resolved base path + + for root, dirs, files in os.walk(path_to_walk, topdown=True): + abs_root_path = Path(root).resolve() + + # --- Directory Pruning (same logic as in _parse_folder) --- + original_dirs = list(dirs) + dirs[:] = [] + for d in original_dirs: + dir_path = abs_root_path / d + if d == ".git" or self._is_path_ignored(str(dir_path)): + continue # Skip ignored or .git dirs + else: + dirs.append(d) + + # --- Process Files in Current Directory --- + sorted_files = sorted(files) + for file_name in sorted_files: + file_path = abs_root_path / file_name + str_file_path = str(file_path) + + # Skip ignored files + if self._is_path_ignored(str_file_path): + if self.verbose: + print(f"Ignoring file (content): {file_path.relative_to(base_path)}") + continue + + # Try to get content + try: + if self.verbose: + print(f"Processing: {file_path.relative_to(base_path)}") + + file_content = self._get_file_contents(str_file_path) + rel_file_path_display = file_path.relative_to(base_path).as_posix() # Display relative path + + content += f"\n\n--- File: {rel_file_path_display} ---\n" + # Optional: Add file type hint + # content += f"File type: {os.path.splitext(file_name)[1]}\n\n" + content += file_content + # Use a clear end marker + content += f"\n--- End File: {rel_file_path_display} ---\n" + + except Exception as e: # Catch potential errors during processing + print(f"Couldn't process {file_path.relative_to(base_path)}: {e}") + content += f"\n\n--- Error processing file: {file_path.relative_to(base_path)} --- \n {e} \n--- End Error ---\n" + + return content + + def get_text(self): + """Generates the final combined text output.""" + # --- Decide whether to clone or use local path --- + process_path = self.input_path # Default to local path + if self.is_github_repo(): + success = self._clone_github_repo() + if success: + process_path = self.temp_folder_path + self.is_cloned_repo = True + # Re-initialize ignores for the cloned repo location + self.input_path = process_path # Temporarily change base for ignore checks + self._initialize_ignores() + print(f"Processing cloned repo at: {process_path}") + else: + print("Error: Failed to clone GitHub repository. Aborting.") + # Reset input_path if cloning failed and we modified it + if self.is_cloned_repo: self.input_path = os.path.dirname(self.temp_folder_path) # Hacky way to get original path back conceptually + return "Error: Could not clone repository." # Return error message + else: + print(f"Processing local path: {process_path}") + # Ensure ignores are initialized for the local path (done in __init__) + + + # --- Generate Structure and Content --- + folder_structure = self._parse_folder(process_path) + file_contents = self._process_files(process_path) + + # --- Assemble Final Output --- + folder_structure_header = "--- Folder Structure ---" + file_contents_header = "--- File Contents ---" + delimiter = "=" * 60 # Use a more prominent delimiter + + # Restore original input_path if it was changed for cloning + if self.is_cloned_repo: + # This assumes the original input_path wasn't needed after _initialize_ignores + # A cleaner way might be to pass the base path explicitly to ignore checkers + pass # No need to restore if input_path wasn't critical after cloning + + return ( + f"{folder_structure_header}\n{folder_structure}\n{delimiter}\n\n" + f"{file_contents_header}\n{file_contents}\n{delimiter}\nEnd of Codebase\n{delimiter}" + ) + + def get_file(self): + """Gets the text and saves it to the specified output file.""" + text_content = self.get_text() + + # Check for error during get_text (e.g., cloning failure) + if text_content.startswith("Error:"): + print(text_content) # Print the error + # Optionally, clean up temp folder even on error + self.clean_up_temp_folder() + return # Exit without writing file + + try: + # Ensure output directory exists + output_dir = os.path.dirname(self.output_path) + if output_dir: # Handle case where output is in current dir + os.makedirs(output_dir, exist_ok=True) + + if self.output_type == "txt": + with open(self.output_path, "w", encoding='utf-8') as file: + file.write(text_content) + elif self.output_type == "docx": + doc = Document() + # Add text respecting paragraphs (simple split, might need refinement) + # Consider adding as preformatted text run if python-docx supports it well + for paragraph in text_content.split('\n'): + doc.add_paragraph(paragraph) + doc.save(self.output_path) + else: + # Should be caught by argparse choices usually, but good to have + raise ValueError(f"Invalid output type '{self.output_type}'. Supported types: txt, docx") + + print(f"\nSuccessfully generated {self.output_type} file: {self.output_path}") + + except Exception as e: + print(f"\nError writing output file {self.output_path}: {e}") + + finally: + # Clean up temp folder regardless of writing success/failure + self.clean_up_temp_folder() + + + #### GitHub #### + def _clone_github_repo(self): + """Clones the repo to a temporary directory.""" + try: + # Create temp dir *before* cloning into it + # Use a more descriptive prefix/suffix if desired + self.temp_folder_path = tempfile.mkdtemp(prefix="cbt_repo_") + print(f"Cloning {self.input_path} into temporary folder {self.temp_folder_path}...") + git.Repo.clone_from(self.input_path, self.temp_folder_path) + + # Important: Update self.input_path to the temp folder *for processing* + # self.input_path = self.temp_folder_path # Now done within get_text + if self.verbose: + print("GitHub repository cloned successfully.") + return True # Indicate success + except git.GitCommandError as e: + print(f"Error cloning GitHub repository: {e}") + # Clean up failed clone attempt + self.clean_up_temp_folder() # Ensure cleanup even on clone failure + self.temp_folder_path = None # Reset path + return False # Indicate failure + except Exception as e: + print(f"An unexpected error occurred during cloning: {e}") + self.clean_up_temp_folder() # Ensure cleanup + self.temp_folder_path = None # Reset path + return False # Indicate failure + + def is_github_repo(self): + """Checks if the input path looks like a common Git repo URL.""" + # Keep it simple, add more patterns if needed + return self.input_path.startswith(("https://github.com/", "git@github.com:", "https://gitlab.com/", "git@gitlab.com:", "https://bitbucket.org/", "git@bitbucket.org:")) \ + or self.input_path.endswith(".git") # Common convention for clone URLs + + def clean_up_temp_folder(self): + """Removes the temporary folder if it was created.""" + if self.temp_folder_path and os.path.exists(self.temp_folder_path): + try: + shutil.rmtree(self.temp_folder_path) + if self.verbose: + print(f"Cleaned up temporary folder: {self.temp_folder_path}") + self.temp_folder_path = None # Reset path after successful removal + except Exception as e: + print(f"Warning: Could not remove temporary folder {self.temp_folder_path}: {e}") + +# --- Main Execution --- +def main(): + parser = argparse.ArgumentParser( + description="Generate a single text or docx file from a codebase, respecting .gitignore and custom ignore rules.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter # Show defaults + ) + parser.add_argument( + "input", + help="Input path (local folder path or Git repository URL)." + ) + parser.add_argument( + "-o", "--output", + required=True, + help="Output file path (e.g., my_codebase.txt or output/report.docx)." + ) + parser.add_argument( + "-t", "--output_type", + choices=["txt", "docx"], + default="txt", + help="Output file type." + ) + parser.add_argument( + "--ignore", + nargs='*', default=[], # Accept zero or more ignore patterns + help="List of additional paths/patterns to ignore (e.g., 'dist/' '/node_modules' '*.log' 'config. Maches relative to the input path root." + ) + parser.add_argument( + "--exclude_hidden", + action="store_true", + help="Exclude files and folders starting with '.' or '__'. Note that .gitignore or custom ignores might already cover these." + ) + parser.add_argument( + "-v", "--verbose", + action="count", default=0, # Use count for verbosity levels (0, 1, 2) + help="Increase output verbosity. -v for basic info, -vv for detailed ignore reasons." + ) + args = parser.parse_args() + + # Basic validation + if not args.input: + parser.error("Input path cannot be empty.") + if not args.output: + parser.error("Output path cannot be empty.") + + + code_to_text = None # Ensure it's defined for finally block + try: + code_to_text = CodebaseToText( + input_path=args.input, + output_path=args.output, + output_type=args.output_type, + verbose=args.verbose, + exclude_hidden=args.exclude_hidden, + ignored_paths=args.ignore # Pass the list here + ) + code_to_text.get_file() + + except Exception as e: + print(f"\nAn unexpected error occurred: {e}") + # Attempt cleanup even if initialization failed partially + if code_to_text: + code_to_text.clean_up_temp_folder() + # No finally block needed here as get_file() now handles cleanup + +def _sql_dump(database, export_file): + command = [ + r'd:\xampp\mysql\bin\mysqldump.exe', + '-u', 'root', + '--no-data', + database + ] + + with open(export_file, 'w') as output_file: + result = subprocess.run(command, stdout=output_file, stderr=subprocess.PIPE) + + if(result.returncode == 0): + print(f"Schema dump successful: {export_file}") + else: + print("Error occurred:", result.stderr.decode()) + + +if __name__ == "__main__": + # --- Example Usage (replace with main() for CLI) --- + + # To run from command line, save the script (e.g., codebase_to_text.py) and run: + # python codebase_to_text.py . -o my_project.txt --ignore "dist/" "*.tmp" "/tests/data/" --exclude_hidden -v + # python codebase_to_text.py https://github.com/user/repo.git -o repo_code.docx -t docx -vv + + # --- Direct call example (useful for testing) --- + try: + print("Running direct example...") + # Example: Process current directory, output to output.txt, ignore 'venv' folder and all '.log' files + example_ignores = ["venv/", "*.log", "/output.txt", ".git/", "__pycache__/"] # Add common ignores + example_ignores.append("public/css/") + example_ignores.append("codebase-to-text.py") + converter = CodebaseToText( + input_path=".", + output_path="_codebase_output.txt", + output_type="txt", + verbose=0, # Set verbosity level (0, 1, or 2) + exclude_hidden=True, + ignored_paths=example_ignores + ) + converter.get_file() + print("Direct example finished.") + + _sql_dump('tp_servicedesk', '_codebase_schemafile.sql') + except Exception as e: + print(f"Error running direct example: {e}") + + # Uncomment the line below to enable command-line argument parsing when running the script directly + # main() + + # to get a sql dump use somthing similar to + # d:\xampp\mysql\bin\mysqldump.exe -u root --no-data tp_servicedesk > _codebase_schemafile.sql +