From 042d088377e44740c636c648f545c9e25c82a6e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:19:36 +0000 Subject: [PATCH 1/5] Initial plan From f9c1bb69df9223e4bcb6770114fdff9e5ca02db0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:26:18 +0000 Subject: [PATCH 2/5] Merge main branch and fix Classic Editor media button Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> --- .github/copilot-instructions.md | 163 ++++ .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 4 +- .github/workflows/phpmd.yml | 4 +- .github/workflows/release.yml | 2 +- .gitlab-ci.yml | 2 +- admin/class-decker-admin-settings.php | 48 ++ admin/class-decker-network-settings.php | 184 +++++ admin/vendor/mime-mail-parser/.gitattributes | 14 - admin/vendor/mime-mail-parser/.gitignore | 8 - .../mime-mail-parser/src/MessagePart.php | 189 +++++ .../mime-mail-parser/src/MimeMailParser.php | 223 +----- composer.json | 6 +- decker.php | 34 + includes/class-decker-calendar.php | 38 +- includes/class-decker-demo-data.php | 321 +++++--- includes/class-decker-email-to-post.php | 52 +- .../class-decker-notification-handler.php | 2 +- .../class-decker-rest-comment-protection.php | 248 ++++++ includes/class-decker-wpcli.php | 2 +- includes/class-decker.php | 13 + .../custom-post-types/class-decker-events.php | 38 +- .../custom-post-types/class-decker-kb.php | 227 +++++- .../custom-post-types/class-decker-tasks.php | 320 +++++++- includes/models/class-task.php | 121 ++- includes/models/class-taskmanager.php | 8 +- languages/decker-es_ES.mo | Bin 29724 -> 34179 bytes languages/decker-es_ES.po | 190 +++-- languages/decker.pot | 100 ++- package.json | 4 +- public/app-analytics.php | 36 +- public/app-calendar.php | 10 +- public/app-event-manager.php | 14 +- public/app-kanban-my.php | 6 +- public/app-kanban.php | 12 +- public/app-knowledge-base.php | 357 ++++++--- public/app-priority.php | 17 +- public/app-task-full.php | 6 +- public/app-tasks.php | 187 +++-- public/app-upcoming.php | 8 +- public/assets/css/app.css | 36 +- public/assets/css/decker-public.css | 325 +++++++- public/assets/js/app.js | 69 +- public/assets/js/config.js | 16 +- public/assets/js/decker-collaboration.js | 701 +++++++++++++++++ public/assets/js/decker-heartbeat.js | 14 +- public/assets/js/decker-public.js | 2 +- public/assets/js/event-calendar.js | 22 +- public/assets/js/event-card.js | 54 +- public/assets/js/event-modal.js | 14 +- public/assets/js/global-search.js | 361 +++++++++ public/assets/js/knowledge-base.js | 739 ++++++++++++++++++ public/assets/js/task-card.js | 2 +- public/class-decker-public.php | 6 +- public/layouts/event-card.php | 30 +- public/layouts/event-modal.php | 2 +- public/layouts/footer-scripts.php | 102 ++- public/layouts/kb-modal.php | 558 ++++--------- public/layouts/kb-view-modal.php | 111 ++- public/layouts/left-sidebar.php | 6 +- public/layouts/task-card.php | 3 + public/layouts/task-modal.php | 2 +- public/layouts/topbar.php | 6 + ...ss-wp-unittest-factory-for-decker-task.php | 29 +- .../DeckerCalendarEventTypeTest.php | 18 +- .../integration/DeckerCalendarICSTDDTest.php | 2 +- .../DeckerTasksAssignTodayTest.php | 4 +- tests/integration/DeckerTasksAuthorTest.php | 96 +++ .../DeckerTasksIntegrationTest.php | 36 +- tests/unit/admin/DeckerAdminSettingsTest.php | 131 ++++ tests/unit/admin/DeckerAdminTest.php | 12 +- .../unit/admin/DeckerNetworkSettingsTest.php | 126 +++ .../DeckerDisableCommentNotificationsTest.php | 22 +- tests/unit/includes/DeckerEmailToPostTest.php | 117 ++- .../DeckerRestCommentProtectionTest.php | 239 ++++++ tests/unit/includes/DeckerTest.php | 24 +- tests/unit/includes/MailerTest.php | 4 +- .../unit/includes/NotificationHandlerTest.php | 8 +- .../CustomPostTypesVisibilityTest.php | 34 + .../DeckerEventsExtraDateTest.php | 42 +- .../custom-post-types/DeckerEventsTest.php | 16 +- .../DeckerTasksCloneTest.php | 375 +++++++++ .../custom-post-types/DeckerTasksRestTest.php | 92 +++ .../custom-post-types/DeckerTasksTest.php | 21 + tests/unit/includes/models/TaskTest.php | 86 ++ tests/unit/public/DeckerPublicTest.php | 182 ++++- 86 files changed, 6573 insertions(+), 1544 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 admin/class-decker-network-settings.php delete mode 100644 admin/vendor/mime-mail-parser/.gitattributes delete mode 100755 admin/vendor/mime-mail-parser/.gitignore create mode 100644 admin/vendor/mime-mail-parser/src/MessagePart.php create mode 100644 includes/class-decker-rest-comment-protection.php create mode 100644 public/assets/js/decker-collaboration.js create mode 100644 public/assets/js/global-search.js create mode 100644 public/assets/js/knowledge-base.js create mode 100644 tests/integration/DeckerTasksAuthorTest.php create mode 100644 tests/unit/admin/DeckerNetworkSettingsTest.php create mode 100644 tests/unit/includes/DeckerRestCommentProtectionTest.php create mode 100644 tests/unit/includes/custom-post-types/CustomPostTypesVisibilityTest.php create mode 100644 tests/unit/includes/custom-post-types/DeckerTasksCloneTest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..8403b242 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,163 @@ +# GitHub Copilot Instructions for Decker WordPress Plugin + +This document provides specific instructions for GitHub Copilot when working on the Decker WordPress plugin. + +## Project Overview + +Decker is a WordPress plugin for task management with a Kanban board interface. It's developed by Área de Tecnología Educativa (ATE) and follows WordPress coding standards. + +## Coding Standards + +### WordPress Coding Standards +- Follow [WordPress Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/) for all PHP, HTML, CSS, and JavaScript code +- Use 4 spaces for indentation (not tabs) +- Limit lines to 80 characters where possible +- Use `snake_case` for functions, methods, and variables +- Use `CamelCase` for class names +- Name files with lowercase letters and hyphens (e.g., `class-decker-admin.php`) + +### PHP Specific +- All PHP functions and methods must have English PHPDoc comments +- Use proper escaping (`esc_html()`, `esc_attr()`, `esc_url()`) for all output +- Sanitize all user inputs using WordPress functions (`sanitize_text_field()`, etc.) +- Use WordPress nonces for form submissions and AJAX requests +- Prefer WordPress APIs over raw PHP functions when available + +### Code Structure +- Keep the main plugin file (`decker.php`) minimal +- Place each class in its own file with pattern `class-pluginname-component.php` +- Admin functionality goes in the `admin/` directory +- Public-facing functionality goes in the `public/` directory +- Shared utilities and custom post types go in the `includes/` directory +- Tests go in the `tests/` directory + +## Language Requirements + +### Source Code +- Write all source code (identifiers, comments, docblocks) in **English** +- Use clear, descriptive names that self-document the code + +### User-Facing Content +- All user-facing strings must be in **Spanish** +- Use WordPress translation functions: `__()`, `_e()`, `_n()`, `_x()` +- Text domain is `decker` +- **Always add Spanish translations** for every new translatable string to `languages/decker-es_ES.po` in the same commit that introduces the string +- Always verify no untranslated Spanish strings remain using `make check-untranslated` + +## Development Workflow + +### Test-Driven Development (TDD) +- Write tests BEFORE implementing features when possible +- Use PHPUnit for PHP tests, Jest for JavaScript tests +- Tests live under `/tests/` directory +- Use factory classes to create test fixtures +- Run tests with `make test` + +### Code Quality +- Run `make lint` to check PHP code style +- Run `make fix` to auto-fix code style issues +- Ensure all linting passes before committing + +### Environment +- Develop within `@wordpress/env` environment +- Start local environment with `make up` +- Access at http://localhost:8888 (admin/password) + +## Security + +### Input/Output Handling +- **Always** validate user inputs +- **Always** sanitize data before storing +- **Always** escape output before displaying +- Use WordPress nonces for all forms and AJAX +- Follow principle of least privilege + +### Best Practices +- Avoid SQL injection by using `$wpdb->prepare()` +- Prevent XSS attacks with proper escaping +- Check user capabilities before performing privileged operations +- Validate file uploads and restrict file types + +## Frontend Technologies + +- Use **Bootstrap 5** for UI components +- Use **jQuery** for JavaScript interactions +- Keep frontend assets minimal +- Enqueue assets properly via `wp_enqueue_script()` and `wp_enqueue_style()` +- Use minified versions in production + +## Common Patterns + +### Adding a New Feature +1. Write failing test(s) first (TDD) +2. Implement minimal code to pass tests +3. Add Spanish translations for every new `__()`, `_e()`, `_n()`, `_x()` call to `languages/decker-es_ES.po` +4. Run `make lint` and `make fix` +5. Run `make test` to verify all tests pass +6. Run `make check-untranslated` to verify translations + +### Creating a New Class +```php +' . esc_html__( 'This setting allows users to manage email notifications in their profile. By default, all notifications are enabled.', 'decker' ) . '

'; } + /** + * Render Collaborative Editing Field. + * + * Outputs the HTML for the collaborative_editing field. + */ + public function collaborative_editing_render() { + $options = get_option( 'decker_settings', array() ); + $checked = isset( $options['collaborative_editing'] ) && '1' === $options['collaborative_editing']; + + echo ''; + echo '

' . esc_html__( 'When enabled, multiple users can edit the same task simultaneously with real-time synchronization using WebRTC.', 'decker' ) . '

'; + } + + /** + * Render Signaling Server Field. + * + * Outputs the HTML for the signaling_server field. + */ + public function signaling_server_render() { + $options = get_option( 'decker_settings', array() ); + $value = isset( $options['signaling_server'] ) ? sanitize_text_field( $options['signaling_server'] ) : 'wss://signaling.yjs.dev'; + + echo ''; + echo '

' . esc_html__( 'WebRTC signaling server URL for collaborative editing. Leave empty to use the default public server (wss://signaling.yjs.dev).', 'decker' ) . '

'; + echo '

' . esc_html__( 'Public servers:', 'decker' ) . '

'; + echo ''; + } + /** * Render User Profile Field. * @@ -250,6 +285,8 @@ public function settings_init() { 'task_editor_type' => __( 'Task Editor Type', 'decker' ), 'shared_key' => __( 'Shared Key', 'decker' ), 'allow_email_notifications' => __( 'Allow Email Notifications', 'decker' ), + 'collaborative_editing' => __( 'Collaborative Editing', 'decker' ), + 'signaling_server' => __( 'Signaling Server', 'decker' ), 'clear_all_data_button' => __( 'Clear All Data', 'decker' ), 'ignored_users' => __( 'Ignored Users', 'decker' ), @@ -385,6 +422,17 @@ public function settings_validate( $input ) { // Validate allow email notifications. $input['allow_email_notifications'] = isset( $input['allow_email_notifications'] ) && '1' === $input['allow_email_notifications'] ? '1' : '0'; + // Validate collaborative editing. + $input['collaborative_editing'] = isset( $input['collaborative_editing'] ) && '1' === $input['collaborative_editing'] ? '1' : '0'; + + // Validate signaling server. + if ( isset( $input['signaling_server'] ) && ! empty( $input['signaling_server'] ) ) { + // Include wss protocol for WebSocket signaling servers. + $input['signaling_server'] = esc_url_raw( $input['signaling_server'], array( 'wss', 'ws', 'https', 'http' ) ); + } else { + $input['signaling_server'] = 'wss://signaling.yjs.dev'; + } + // Validate alert color. $valid_colors = array( 'success', 'danger', 'warning', 'info' ); if ( isset( $input['alert_color'] ) && ! in_array( $input['alert_color'], $valid_colors ) ) { diff --git a/admin/class-decker-network-settings.php b/admin/class-decker-network-settings.php new file mode 100644 index 00000000..9d0ab084 --- /dev/null +++ b/admin/class-decker-network-settings.php @@ -0,0 +1,184 @@ + +
+

+ +
+

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

+ +

+
+ +
+
+ 0 ) { + $valid_ids[] = intval( $id ); + } + } + $allowed_sites = implode( ',', $valid_ids ); + } + + update_site_option( self::OPTION_NAME, $allowed_sites ); + + wp_redirect( + add_query_arg( + array( + 'page' => 'decker_network_settings', + 'updated' => 'true', + ), + network_admin_url( 'settings.php' ) + ) + ); + exit; + } + + /** + * Get the list of allowed site IDs from the network option. + * + * Returns an empty array when no restriction is configured, meaning all + * sites are allowed. + * + * @return int[] Array of allowed site IDs. Empty array means no restriction. + */ + public static function get_allowed_sites() { + $option = get_site_option( self::OPTION_NAME, '' ); + if ( empty( $option ) ) { + return array(); + } + return array_values( + array_filter( + array_map( 'intval', array_filter( array_map( 'trim', explode( ',', $option ) ) ) ) + ) + ); + } + + /** + * Check whether a given site is allowed to activate the plugin. + * + * When the allowlist is empty no restriction is enforced and every site + * is considered allowed (backward-compatible default). + * + * @param int $site_id The site ID to check. + * @return bool True if the site is allowed or no restriction is configured. + */ + public static function is_site_allowed( $site_id ) { + $allowed_sites = self::get_allowed_sites(); + if ( empty( $allowed_sites ) ) { + return true; + } + return in_array( (int) $site_id, $allowed_sites, true ); + } +} diff --git a/admin/vendor/mime-mail-parser/.gitattributes b/admin/vendor/mime-mail-parser/.gitattributes deleted file mode 100644 index 5a578d9b..00000000 --- a/admin/vendor/mime-mail-parser/.gitattributes +++ /dev/null @@ -1,14 +0,0 @@ -/.distignore export-ignore -/.editorconfig export-ignore -/.env export-ignore -/.github/ export-ignore -/.php-cs-fixer.dist.php export-ignore -/docs/ export-ignore -/mkdocs.yml export-ignore -/tests/ export-ignore -/vendor/ export-ignore -composer.lock export-ignore -phpunit.xml export-ignore -README.md export-ignore -renovate.json export-ignore -test.php export-ignore diff --git a/admin/vendor/mime-mail-parser/.gitignore b/admin/vendor/mime-mail-parser/.gitignore deleted file mode 100755 index 8f4e018c..00000000 --- a/admin/vendor/mime-mail-parser/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -vendor -composer.lock -.idea -.DS_Store -.aider* -.env -.phpunit.result.cache -tester.php \ No newline at end of file diff --git a/admin/vendor/mime-mail-parser/src/MessagePart.php b/admin/vendor/mime-mail-parser/src/MessagePart.php new file mode 100644 index 00000000..1d96741e --- /dev/null +++ b/admin/vendor/mime-mail-parser/src/MessagePart.php @@ -0,0 +1,189 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/erseco/mime-mail-parser + */ + +namespace Erseco; + +/** + * MessagePart class for handling individual parts of an email message + * + * This class represents a single part of an email message, which could be + * the body text, HTML content, or an attachment. + * + * @category Library + * @package MimeMailParser + * @author Ernesto Serrano + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/erseco/mime-mail-parser + */ +class MessagePart implements \JsonSerializable +{ + protected string $content; + + protected array $headers; + + /** + * Create a new MessagePart instance + * + * @param string $content The content of the message part + * @param array $headers The headers associated with this part + */ + public function __construct(string $content, array $headers = []) + { + $this->content = $content; + $this->headers = $headers; + } + + /** + * Get the content type of this message part + * + * @return string The content type or empty string if not set + */ + public function getContentType(): string + { + return $this->headers['Content-Type'] ?? ''; + } + + /** + * Get all headers for this message part + * + * @return array Array of headers + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get a specific header value + * + * @param string $name The name of the header to retrieve + * @param mixed $default Default value if header not found + * + * @return mixed The header value or default if not found + */ + public function getHeader(string $name, $default = null): mixed + { + return $this->headers[$name] ?? $default; + } + + /** + * Get the decoded content of this message part + * + * @return string The decoded content + */ + public function getContent(): string + { + $content = $this->content; + $encoding = strtolower($this->getHeader('Content-Transfer-Encoding', '')); + + if ($encoding === 'base64') { + return base64_decode($content); + } elseif ($encoding === 'quoted-printable') { + return quoted_printable_decode($content); + } + + return $content; + } + + /** + * Check if this part is HTML content + * + * @return bool True if content type is text/html + */ + public function isHtml(): bool + { + return str_starts_with(strtolower($this->getContentType()), 'text/html'); + } + + /** + * Check if this part is plain text content + * + * @return bool True if content type is text/plain + */ + public function isText(): bool + { + return str_starts_with(strtolower($this->getContentType()), 'text/plain'); + } + + /** + * Check if this part is an image + * + * @return bool True if content type starts with image/ + */ + public function isImage(): bool + { + return str_starts_with(strtolower($this->getContentType()), 'image/'); + } + + /** + * Check if this part is an attachment + * + * @return bool True if content disposition is attachment + */ + public function isAttachment(): bool + { + return str_starts_with($this->getHeader('Content-Disposition', ''), 'attachment'); + } + + /** + * Get the filename of this part if it's an attachment + * + * @return string The filename or empty string if not found + */ + public function getFilename(): string + { + if (preg_match('/filename=([^;]+)/', $this->getHeader('Content-Disposition'), $matches)) { + return trim($matches[1], '"'); + } + + if (preg_match('/name=([^;]+)/', $this->getContentType(), $matches)) { + return trim($matches[1], '"'); + } + + return ''; + } + + /** + * Get the size of the content in bytes + * + * @return int Size in bytes + */ + public function getSize(): int + { + return strlen($this->getContent()); + } + + /** + * Convert the message part to an array representation + * + * @return array Array containing message part data including headers, content, filename, and size + */ + public function toArray(): array + { + return [ + 'headers' => $this->getHeaders(), + 'content' => $this->getContent(), + 'filename' => $this->getFilename(), + 'size' => $this->getSize(), + ]; + } + + /** + * Specify data which should be serialized to JSON + * + * @return array Array containing message part data + */ + public function jsonSerialize(): mixed + { + return $this->toArray(); + } +} diff --git a/admin/vendor/mime-mail-parser/src/MimeMailParser.php b/admin/vendor/mime-mail-parser/src/MimeMailParser.php index b850a74d..3406b786 100644 --- a/admin/vendor/mime-mail-parser/src/MimeMailParser.php +++ b/admin/vendor/mime-mail-parser/src/MimeMailParser.php @@ -12,6 +12,8 @@ namespace Erseco; +require_once __DIR__ . '/MessagePart.php'; + /** * MimeMailParser class for parsing email messages * @@ -233,7 +235,7 @@ public function getTextPart(): ?MessagePart /** * Get the attachments of a message - * + * * @return MessagePart[] */ public function getAttachments(): array @@ -296,6 +298,7 @@ protected function parse(bool $ignoreSignature): void $headerInProgress = null; $collectingBody = false; + $bodyContentStarted = false; $currentBody = ''; $currentBodyHeaders = []; $currentBodyHeaderInProgress = null; @@ -309,7 +312,7 @@ protected function parse(bool $ignoreSignature): void $this->headers[$headerInProgress] = ''; } $this->headers[$headerInProgress] .= PHP_EOL . $line; - + $headerInProgress = str_ends_with($this->headers[$headerInProgress], ';'); continue; } @@ -322,15 +325,15 @@ protected function parse(bool $ignoreSignature): void } // Check for multipart boundary end - if (isset($this->boundary) && str_ends_with($line, '--'.$this->boundary.'--')) { - $line = str_replace('--'.$this->boundary.'--', '', $line); + if (isset($this->boundary) && str_ends_with($line, '--' . $this->boundary . '--')) { + $line = str_replace('--' . $this->boundary . '--', '', $line); $currentBody .= $line; break; } // Check for multipart boundary - if (isset($this->boundary) && str_ends_with($line, '--'.$this->boundary)) { - $line = str_replace('--'.$this->boundary, '', $line); + if (isset($this->boundary) && str_ends_with($line, '--' . $this->boundary)) { + $line = str_replace('--' . $this->boundary, '', $line); // Add the previous part before starting a new one if ($collectingBody) { @@ -338,13 +341,14 @@ protected function parse(bool $ignoreSignature): void } $collectingBody = true; + $bodyContentStarted = false; $currentBody = ''; $currentBodyHeaders = []; continue; } - // Collect body headers - if ($collectingBody && preg_match('/^(?[A-Za-z\-0-9]+): (?.*)$/', $line, $matches)) { + // Collect body headers (only before body content starts) + if ($collectingBody && !$bodyContentStarted && preg_match('/^(?[A-Za-z\-0-9]+): (?.*)$/', $line, $matches)) { $currentBodyHeaders[$matches['key']] = $matches['value']; // Check for continued headers @@ -357,26 +361,29 @@ protected function parse(bool $ignoreSignature): void // Collect body content if ($collectingBody) { + $bodyContentStarted = true; + // Special handling for boundary close line - if (isset($this->boundary) && str_contains($line, '--'.$this->boundary)) { - $parts = explode('--'.$this->boundary, $line); + if (isset($this->boundary) && str_contains($line, '--' . $this->boundary)) { + $parts = explode('--' . $this->boundary, $line); $currentBody .= $parts[0]; - + // Add part and prepare for next $this->addPart($currentBody, $currentBodyHeaders); $currentBody = ''; $currentBodyHeaders = []; + $bodyContentStarted = false; $collectingBody = true; continue; } - + $currentBody .= $line . PHP_EOL; continue; } // Detect multipart content type with boundary if (preg_match("/^Content-Type: (?multipart\/.*); boundary=(?.*)$/", $line, $matches)) { - $this->headers['Content-Type'] = $matches['contenttype']."; boundary=".$matches['boundary']; + $this->headers['Content-Type'] = $matches['contenttype'] . "; boundary=" . $matches['boundary']; $this->boundary = trim($matches['boundary'], '"'); continue; } @@ -384,7 +391,7 @@ protected function parse(bool $ignoreSignature): void // Collect message headers if (preg_match('/^(?[A-Za-z\-0-9]+): (?.*)$/', $line, $matches)) { // Special handling for single-part messages or non-multipart content types - if (strtolower($matches['key']) === 'content-type' && !isset($this->boundary) && !str_contains($matches['value'], 'multipart/mixed')) { + if (strtolower($matches['key']) === 'content-type' && !isset($this->boundary) && !str_contains($matches['value'], 'multipart/')) { $collectingBody = true; $currentBody = ''; $currentBodyHeaders = [ @@ -412,6 +419,7 @@ protected function parse(bool $ignoreSignature): void if (preg_match("~^--(?[0-9A-Za-z'()+_,-./:=?]{0,68}[0-9A-Za-z'()+_,-./=?])~", $line, $matches)) { $this->boundary = trim($matches['boundary']); $collectingBody = true; + $bodyContentStarted = false; $currentBody = ''; $currentBodyHeaders = []; continue; @@ -447,182 +455,17 @@ protected function parse(bool $ignoreSignature): void */ protected function addPart(string $currentBody, array $currentBodyHeaders): void { - $this->parts[] = new MessagePart(trim($currentBody), $currentBodyHeaders); - } -} - -/** - * MessagePart class for handling individual parts of an email message - * - * This class represents a single part of an email message, which could be - * the body text, HTML content, or an attachment. - * - * @category Library - * @package MimeMailParser - * @author Ernesto Serrano - * @license MIT https://opensource.org/licenses/MIT - * @link https://github.com/erseco/mime-mail-parser - */ -class MessagePart implements \JsonSerializable -{ - protected string $content; - - protected array $headers; - - /** - * Create a new MessagePart instance - * - * @param string $content The content of the message part - * @param array $headers The headers associated with this part - */ - public function __construct(string $content, array $headers = []) - { - $this->content = $content; - $this->headers = $headers; - } - - /** - * Get the content type of this message part - * - * @return string The content type or empty string if not set - */ - public function getContentType(): string - { - return $this->headers['Content-Type'] ?? ''; - } - - /** - * Get all headers for this message part - * - * @return array Array of headers - */ - public function getHeaders(): array - { - return $this->headers; - } - - /** - * Get a specific header value - * - * @param string $name The name of the header to retrieve - * @param mixed $default Default value if header not found - * - * @return mixed The header value or default if not found - */ - public function getHeader(string $name, $default = null): mixed - { - return $this->headers[$name] ?? $default; - } - - /** - * Get the decoded content of this message part - * - * @return string The decoded content - */ - public function getContent(): string - { - $content = $this->content; - $encoding = strtolower($this->getHeader('Content-Transfer-Encoding', '')); - - if ($encoding === 'base64') { - return base64_decode($content); - } elseif ($encoding === 'quoted-printable') { - return quoted_printable_decode($content); - } - - return $content; - } - - /** - * Check if this part is HTML content - * - * @return bool True if content type is text/html - */ - public function isHtml(): bool - { - return str_starts_with(strtolower($this->getContentType()), 'text/html'); - } - - /** - * Check if this part is plain text content - * - * @return bool True if content type is text/plain - */ - public function isText(): bool - { - return str_starts_with(strtolower($this->getContentType()), 'text/plain'); - } - - /** - * Check if this part is an image - * - * @return bool True if content type starts with image/ - */ - public function isImage(): bool - { - return str_starts_with(strtolower($this->getContentType()), 'image/'); - } - - /** - * Check if this part is an attachment - * - * @return bool True if content disposition is attachment - */ - public function isAttachment(): bool - { - return str_starts_with($this->getHeader('Content-Disposition', ''), 'attachment'); - } - - /** - * Get the filename of this part if it's an attachment - * - * @return string The filename or empty string if not found - */ - public function getFilename(): string - { - if (preg_match('/filename=([^;]+)/', $this->getHeader('Content-Disposition'), $matches)) { - return trim($matches[1], '"'); - } - - if (preg_match('/name=([^;]+)/', $this->getContentType(), $matches)) { - return trim($matches[1], '"'); + $contentType = $currentBodyHeaders['Content-Type'] ?? ''; + if (str_contains(strtolower($contentType), 'multipart/')) { + if (preg_match('/boundary=["\']?([^"\';\s]+)/i', $contentType, $matches)) { + $innerMessage = "Content-Type: " . $contentType . "\n\n" . $currentBody; + $innerParser = new self($innerMessage); + foreach ($innerParser->getParts() as $innerPart) { + $this->parts[] = $innerPart; + } + return; + } } - - return ''; - } - - /** - * Get the size of the content in bytes - * - * @return int Size in bytes - */ - public function getSize(): int - { - return strlen($this->getContent()); - } - - /** - * Convert the message part to an array representation - * - * @return array Array containing message part data including headers, content, filename, and size - */ - public function toArray(): array - { - return [ - 'headers' => $this->getHeaders(), - 'content' => $this->getContent(), - 'filename' => $this->getFilename(), - 'size' => $this->getSize(), - ]; - } - - /** - * Specify data which should be serialized to JSON - * - * @return array Array containing message part data - */ - public function jsonSerialize(): mixed - { - return $this->toArray(); + $this->parts[] = new MessagePart(trim($currentBody), $currentBodyHeaders); } } diff --git a/composer.json b/composer.json index 1cf0415e..b1078030 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,14 @@ { "require": { - "erseco/mime-mail-parser": "^1.0" + "erseco/mime-mail-parser": "*" }, "require-dev": { "om/icalparser": "*", "phpcompatibility/phpcompatibility-wp": "*", "phpunit/phpunit": "*", - "squizlabs/php_codesniffer": "^3.11", + "squizlabs/php_codesniffer": "*", "wp-cli/i18n-command": "*", - "wp-coding-standards/wpcs": "^3.1", + "wp-coding-standards/wpcs": "*", "yoast/phpunit-polyfills": "*", "yoast/wp-test-utils": "*" }, diff --git a/decker.php b/decker.php index 6b6b1883..204244d4 100644 --- a/decker.php +++ b/decker.php @@ -31,6 +31,18 @@ * The code that runs during plugin activation. */ function activate_decker() { + // In a Multisite network, verify the site is in the allowlist. + if ( is_multisite() && ! Decker_Network_Settings::is_site_allowed( get_current_blog_id() ) ) { + wp_die( + esc_html__( + 'Decker cannot be activated on this site. Please ask your network administrator to add this site to the allowed sites list under Network Admin > Settings > Decker.', + 'decker' + ), + esc_html__( 'Plugin Activation Error', 'decker' ), + array( 'back_link' => true ) + ); + } + // Set the permalink structure if necessary. if ( '/%postname%/' !== get_option( 'permalink_structure' ) ) { update_option( 'permalink_structure', '/%postname%/' ); @@ -92,6 +104,28 @@ function decker_maybe_flush_rewrite_rules() { add_action( 'init', 'decker_maybe_flush_rewrite_rules', 999 ); +/** + * Show an admin notice when Decker is active on a site excluded from the + * network allowlist, so administrators are aware of the mismatch. + */ +function decker_multisite_restriction_notice() { + if ( ! is_multisite() ) { + return; + } + if ( ! class_exists( 'Decker_Network_Settings' ) ) { + return; + } + if ( ! Decker_Network_Settings::is_site_allowed( get_current_blog_id() ) ) { + echo '

' . + esc_html__( + 'Decker is active on this site, but the network administrator has not included it in the allowed sites list. Please contact your network administrator.', + 'decker' + ) . + '

'; + } +} +add_action( 'admin_notices', 'decker_multisite_restriction_notice' ); + /** * The core plugin class that is used to define internationalization, * admin-specific hooks, and public-facing site hooks. diff --git a/includes/class-decker-calendar.php b/includes/class-decker-calendar.php index 318d09d2..a6233508 100644 --- a/includes/class-decker-calendar.php +++ b/includes/class-decker-calendar.php @@ -126,7 +126,7 @@ public function flush_cache_for_task( $post_id = 0 ) { /** * Flush ONLY the cache that matches the event's current category, - * plus the global «all» variante. + * plus the global «all» variant. * * @param int|WP_Post $post_id Post ID or object. */ @@ -234,7 +234,7 @@ public function add_ical_endpoint() { */ public function get_calendar_permissions_check( $request ) { - // Verificar nonce de REST API primero. + // Check REST API nonce first. $nonce = $request->get_header( 'X-WP-Nonce' ); if ( wp_verify_nonce( $nonce, 'wp_rest' ) ) { return true; @@ -287,7 +287,7 @@ public function get_calendar_json( $request ) { public function handle_ical_request() { global $wp_query; - // Aceptar tanto query var interno como parámetro GET (?decker-calendar). + // Accept both an internal query var and a GET parameter (?decker-calendar). if ( ! isset( $wp_query->query_vars['decker-calendar'] ) && ! isset( $_GET['decker-calendar'] ) ) { return; } @@ -303,8 +303,8 @@ public function handle_ical_request() { // Cached generation. $ical = $this->get_cached_ical( $type ); - // Evitar advertencias “Cannot modify header information” cuando la salida ya comenzó - // (p. ej. en PHPUnit) comprobando headers_sent() antes de enviar cabeceras. + // Avoid “Cannot modify header information” warnings when output has already started + // (e.g., in PHPUnit) by checking headers_sent() before sending headers. if ( ! headers_sent() && ! ( defined( 'WP_TESTS_RUNNING' ) && WP_TESTS_RUNNING ) ) { header( 'Content-Type: text/calendar; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="decker-calendar.ics"' ); @@ -316,8 +316,8 @@ public function handle_ical_request() { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is safe iCal content echo $ical; - // Durante las pruebas (CLI/PHPUnit o WP-CLI) no detenemos la ejecución. - // Solo salimos en peticiones web normales para evitar contenido extra. + // During tests (CLI/PHPUnit or WP-CLI) we do not stop execution. + // Only exit on normal web requests to avoid extra content. if ( php_sapi_name() !== 'cli' && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) { exit; } @@ -350,7 +350,7 @@ public function get_events( $type = '' ) { $post = $event_data['post']; $meta = $event_data['meta']; - // Asegurarse de que las fechas sean válidas antes de agregarlas. + // Ensure that the dates are valid before adding them. if ( ! empty( $meta['event_start'] ) && ! empty( $meta['event_end'] ) ) { $all_day = isset( $meta['event_allday'] ) ? $meta['event_allday'][0] : false; @@ -365,7 +365,7 @@ public function get_events( $type = '' ) { $events[] = array( 'post_id' => $post->ID, - 'id' => 'event_' . $post->ID, // Prefijo para distinguir de tareas. + 'id' => 'event_' . $post->ID, // Prefix to distinguish from tasks. 'title' => $post->post_title, 'description' => $post->post_content, 'allDay' => $all_day, @@ -381,7 +381,7 @@ public function get_events( $type = '' ) { } } - // Añadir tareas solo cuando no se está filtrando por un tipo concreto. + // Add tasks only when not filtering by a specific type. if ( empty( $type ) ) { // Get published tasks. $task_manager = new TaskManager(); @@ -414,7 +414,7 @@ function ( $user ) { ); } } - } // Fin condicional tareas + } // End tasks conditional return $events; } @@ -438,23 +438,23 @@ public function generate_ical( $events, $type = '' ) { $calendar_name = 'Decker - ' . $type_names[ $type ]; } - $ical .= 'PRODID:-//' . $this->ical_escape( $calendar_name ) . "//NONSGML Decker//EN\r\n"; // ¡Clave! + $ical .= 'PRODID:-//' . $this->ical_escape( $calendar_name ) . "//NONSGML Decker//EN\r\n"; // Key property. $ical .= "CALSCALE:GREGORIAN\r\n"; $ical .= "METHOD:PUBLISH\r\n"; $ical .= "X-WR-TIMEZONE:UTC\r\n"; - // Set the refresch interval. + // Set the refresh interval. $ttl = 'PT1H'; // 1h. $ical .= "REFRESH-INTERVAL;VALUE=DURATION:$ttl\r\n"; $ical .= "X-PUBLISHED-TTL:$ttl\r\n"; - // Añadir punto final a comentario. + // Add a period at the end of the comment. $ical .= 'X-WR-CALNAME:' . $this->ical_escape( $calendar_name ) . "\r\n"; $ical .= 'X-NAME:' . $this->ical_escape( $calendar_name ) . "\r\n"; - // Ordenar eventos por fecha de inicio ascendente para garantizar resultados - // deterministas y alinear con las expectativas de las pruebas. + // Sort events by ascending start date to ensure deterministic results + // and align with test expectations. usort( $events, function ( $a, $b ) { @@ -469,9 +469,9 @@ function ( $a, $b ) { $ical .= 'SEQUENCE:' . get_post_modified_time( 'U', true, $event['post_id'] ) . "\r\n"; $ical .= 'DTSTAMP:' . gmdate( 'Ymd\THis\Z' ) . "\r\n"; - // Formatear fechas para eventos de día completo o con hora. + // Format dates for all-day events or with time. if ( ! empty( $event['allDay'] ) && ( true === $event['allDay'] || '1' === $event['allDay'] || 1 === $event['allDay'] ) ) { - // Para eventos de día completo, usar formato VALUE=DATE y DTEND al día siguiente. + // For all-day events, use format VALUE=DATE and DTEND on the next day. $start_date = gmdate( 'Ymd', strtotime( $event['start'] ) ); $end_date = gmdate( 'Ymd', strtotime( $event['end'] ) ); @@ -498,7 +498,7 @@ function ( $a, $b ) { } } - // We use » because : had codification problemas on ical. + // We use » because : had encoding problems in iCal. $users_prefix = implode( ', ', $display_names ) . ' » '; } diff --git a/includes/class-decker-demo-data.php b/includes/class-decker-demo-data.php index 96ade697..d91f714e 100644 --- a/includes/class-decker-demo-data.php +++ b/includes/class-decker-demo-data.php @@ -45,8 +45,22 @@ public function create_sample_data() { */ private function create_labels() { $labels = array(); - for ( $i = 1; $i <= 10; $i++ ) { - $term_name = "Label $i"; + + // Create labels with varying lengths for better testing. + $label_names = array( + 'Bug', + 'Feature', + 'Urgent Priority', + 'Documentation', + 'Needs Review', + 'In Progress', + 'Testing Required', + 'UI', + 'Backend Development', + 'Critical Security Issue', + ); + + foreach ( $label_names as $term_name ) { $term_slug = sanitize_title( $term_name ); $term_color = $this->generate_random_color(); @@ -83,55 +97,55 @@ private function create_boards() { $visibility_settings = array( // Board 1: Visible in both Boards and KB. array( - 'name' => 'Board 1', + 'name' => 'Project Alpha', 'show_in_boards' => true, 'show_in_kb' => true, ), // Board 2: Visible only in Boards. array( - 'name' => 'Board 2', + 'name' => 'Marketing Campaign Q1 2024', 'show_in_boards' => true, 'show_in_kb' => false, ), // Board 3: Visible only in KB. array( - 'name' => 'Board 3', + 'name' => 'Dev', 'show_in_boards' => false, 'show_in_kb' => true, ), // Board 4: Not visible in either (hidden). array( - 'name' => 'Board 4', + 'name' => 'Customer Support and Success Team', 'show_in_boards' => false, 'show_in_kb' => false, ), // Board 5: Visible in both. array( - 'name' => 'Board 5', + 'name' => 'HR', 'show_in_boards' => true, 'show_in_kb' => true, ), // Board 6: Visible in both. array( - 'name' => 'Board 6', + 'name' => 'Infrastructure and DevOps', 'show_in_boards' => true, 'show_in_kb' => true, ), // Board 7: Visible only in Boards. array( - 'name' => 'Board 7', + 'name' => 'Research', 'show_in_boards' => true, 'show_in_kb' => false, ), // Board 8: Visible only in KB. array( - 'name' => 'Board 8', + 'name' => 'Quality Assurance and Testing', 'show_in_boards' => false, 'show_in_kb' => true, ), // Board 9: Visible in both. array( - 'name' => 'Board 9', + 'name' => 'Sales', 'show_in_boards' => true, 'show_in_kb' => true, ), @@ -203,125 +217,170 @@ private function create_kb_articles( $labels ) { return; } - // Create main categories. - $categories = array( - 'Getting Started' => array( - 'Introduction' => $lorem_ipsum['medium'], - 'Quick Start Guide' => $lorem_ipsum['long'], - 'Basic Concepts' => $lorem_ipsum['medium'], - ), - 'User Guide' => array( - 'Dashboard Overview' => $lorem_ipsum['medium'], - 'Managing Tasks' => array( - 'Creating Tasks' => $lorem_ipsum['short'], - 'Editing Tasks' => $lorem_ipsum['medium'], - 'Deleting Tasks' => $lorem_ipsum['short'], + // Create main categories; include deeper hierarchy for demo (3+ levels). + $categories = array( + 'Getting Started' => array( + 'Introduction' => $lorem_ipsum['medium'], + 'Quick Start Guide' => $lorem_ipsum['long'], + 'Basic Concepts' => $lorem_ipsum['medium'], ), - 'Working with Boards' => array( - 'Board Setup' => $lorem_ipsum['medium'], - 'Managing Columns' => $lorem_ipsum['long'], + 'User Guide' => array( + 'Dashboard Overview' => $lorem_ipsum['medium'], + 'Managing Tasks' => array( + 'Creating Tasks' => $lorem_ipsum['short'], + 'Editing Tasks' => array( + 'Basic Edits' => $lorem_ipsum['medium'], + 'Advanced Edits' => array( + 'Keyboard Shortcuts' => $lorem_ipsum['short'], + 'Bulk Changes' => $lorem_ipsum['short'], + ), + ), + 'Deleting Tasks' => $lorem_ipsum['short'], + ), + 'Working with Boards' => array( + 'Board Setup' => $lorem_ipsum['medium'], + 'Managing Columns' => $lorem_ipsum['long'], + ), ), - ), - 'Advanced Features' => array( - 'API Integration' => $lorem_ipsum['long'], - 'Custom Workflows' => $lorem_ipsum['medium'], - 'Automation Rules' => $lorem_ipsum['medium'], - ), - ); + 'Advanced Features' => array( + 'API Integration' => array( + 'Authentication' => $lorem_ipsum['medium'], + 'Endpoints' => array( + 'GET /tasks' => $lorem_ipsum['short'], + 'POST /tasks' => $lorem_ipsum['short'], + ), + ), + 'Custom Workflows' => $lorem_ipsum['medium'], + 'Automation Rules' => $lorem_ipsum['medium'], + ), + ); - // Create articles for each KB-visible board. - foreach ( $kb_boards as $board_term ) { - // For each board, create a set of articles. - foreach ( $categories as $main_title => $subcategories ) { - // Create a unique title for this board. - $board_main_title = $main_title . ' - ' . $board_term->name; + // Create articles for each KB-visible board. + foreach ( $kb_boards as $board_term ) { + // For each board, create a set of articles. + foreach ( $categories as $main_title => $subcategories ) { + // Create main category article (no board suffix in title). + $main_post_id = wp_insert_post( + array( + 'post_type' => 'decker_kb', + 'post_title' => $main_title, + 'post_content' => $lorem_ipsum['short'], + 'post_status' => 'publish', + 'menu_order' => 0, + ) + ); + + // Assign random labels (1-2) to main category. + $main_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); + wp_set_object_terms( $main_post_id, $main_labels, 'decker_label' ); + + // Assign the board. + wp_set_object_terms( $main_post_id, array( $board_term->term_id ), 'decker_board' ); + + $order = 0; + foreach ( $subcategories as $sub_title => $content ) { + if ( is_array( $content ) ) { + // This is a subcategory with its own children. + $sub_post_id = wp_insert_post( + array( + 'post_type' => 'decker_kb', + 'post_title' => $sub_title, + 'post_content' => $lorem_ipsum['medium'], + 'post_status' => 'publish', + 'post_parent' => $main_post_id, + 'menu_order' => $order, + ) + ); - // Create main category article. - $main_post_id = wp_insert_post( - array( - 'post_type' => 'decker_kb', - 'post_title' => $board_main_title, - 'post_content' => $lorem_ipsum['short'], - 'post_status' => 'publish', - 'menu_order' => 0, - ) - ); + // Assign random labels to subcategory. + $sub_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); + wp_set_object_terms( $sub_post_id, $sub_labels, 'decker_label' ); - // Assign random labels (1-2) to main category. - $main_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); - wp_set_object_terms( $main_post_id, $main_labels, 'decker_label' ); - - // Assign the board. - wp_set_object_terms( $main_post_id, array( $board_term->term_id ), 'decker_board' ); - - $order = 0; - foreach ( $subcategories as $sub_title => $content ) { - if ( is_array( $content ) ) { - // This is a subcategory with its own children. - $sub_post_id = wp_insert_post( - array( - 'post_type' => 'decker_kb', - 'post_title' => $sub_title, - 'post_content' => $lorem_ipsum['medium'], - 'post_status' => 'publish', - 'post_parent' => $main_post_id, - 'menu_order' => $order, - ) - ); - - // Assign random labels to subcategory. - $sub_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); - wp_set_object_terms( $sub_post_id, $sub_labels, 'decker_label' ); - - // Assign the same board as parent. - wp_set_object_terms( $sub_post_id, array( $board_term->term_id ), 'decker_board' ); - - $sub_order = 0; - foreach ( $content as $child_title => $child_content ) { - $child_post_id = wp_insert_post( + // Assign the same board as parent. + wp_set_object_terms( $sub_post_id, array( $board_term->term_id ), 'decker_board' ); + + $sub_order = 0; + foreach ( $content as $child_title => $child_content ) { + if ( is_array( $child_content ) ) { + // Child branch with grandchildren. + $child_post_id = wp_insert_post( + array( + 'post_type' => 'decker_kb', + 'post_title' => $child_title, + 'post_content' => $lorem_ipsum['medium'], + 'post_status' => 'publish', + 'post_parent' => $sub_post_id, + 'menu_order' => $sub_order, + ) + ); + + $child_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); + wp_set_object_terms( $child_post_id, $child_labels, 'decker_label' ); + wp_set_object_terms( $child_post_id, array( $board_term->term_id ), 'decker_board' ); + + $gg_order = 0; + foreach ( $child_content as $g_title => $g_content ) { + $gc_post_id = wp_insert_post( + array( + 'post_type' => 'decker_kb', + 'post_title' => $g_title, + 'post_content' => is_array( $g_content ) ? $lorem_ipsum['short'] : $g_content, + 'post_status' => 'publish', + 'post_parent' => $child_post_id, + 'menu_order' => $gg_order, + ) + ); + $gc_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); + wp_set_object_terms( $gc_post_id, $gc_labels, 'decker_label' ); + wp_set_object_terms( $gc_post_id, array( $board_term->term_id ), 'decker_board' ); + $gg_order++; + } + $sub_order++; + } else { + // Leaf child. + $child_post_id = wp_insert_post( + array( + 'post_type' => 'decker_kb', + 'post_title' => $child_title, + 'post_content' => $child_content, + 'post_status' => 'publish', + 'post_parent' => $sub_post_id, + 'menu_order' => $sub_order, + ) + ); + + // Assign random labels to child and same board. + $child_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); + wp_set_object_terms( $child_post_id, $child_labels, 'decker_label' ); + wp_set_object_terms( $child_post_id, array( $board_term->term_id ), 'decker_board' ); + + $sub_order++; + } + } + } else { + // This is a direct subcategory. + $sub_post_id = wp_insert_post( array( 'post_type' => 'decker_kb', - 'post_title' => $child_title, - 'post_content' => $child_content, + 'post_title' => $sub_title, + 'post_content' => $content, 'post_status' => 'publish', - 'post_parent' => $sub_post_id, - 'menu_order' => $sub_order, + 'post_parent' => $main_post_id, + 'menu_order' => $order, ) ); - // Assign random labels to child. - $child_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); - wp_set_object_terms( $child_post_id, $child_labels, 'decker_label' ); + // Assign random labels to subcategory. + $sub_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); + wp_set_object_terms( $sub_post_id, $sub_labels, 'decker_label' ); // Assign the same board as parent. - wp_set_object_terms( $child_post_id, array( $board_term->term_id ), 'decker_board' ); - - $sub_order++; + wp_set_object_terms( $sub_post_id, array( $board_term->term_id ), 'decker_board' ); } - } else { - // This is a direct subcategory. - $sub_post_id = wp_insert_post( - array( - 'post_type' => 'decker_kb', - 'post_title' => $sub_title, - 'post_content' => $content, - 'post_status' => 'publish', - 'post_parent' => $main_post_id, - 'menu_order' => $order, - ) - ); - - // Assign random labels to subcategory. - $sub_labels = $this->wp_rand_elements( $labels, $this->custom_rand( 1, 2 ) ); - wp_set_object_terms( $sub_post_id, $sub_labels, 'decker_label' ); - - // Assign the same board as parent. - wp_set_object_terms( $sub_post_id, array( $board_term->term_id ), 'decker_board' ); + $order++; } - $order++; } } - } } /** @@ -496,7 +555,41 @@ private function create_tasks( $boards, $labels ) { } for ( $j = 1; $j <= $num_tasks; $j++ ) { - $post_title = "Task $j for {$board->name}"; + // Create task titles with varying lengths for better testing. + $short_titles = array( + 'Fix bug', + 'Update docs', + 'Review PR', + 'Deploy', + 'Test', + ); + + $medium_titles = array( + 'Implement new feature', + 'Refactor database queries', + 'Update user interface', + 'Configure deployment pipeline', + 'Write unit tests', + ); + + $long_titles = array( + 'Investigate performance issues in the production environment', + 'Develop comprehensive documentation for API endpoints', + 'Implement user authentication and authorization system', + 'Optimize database queries for improved application performance', + 'Create automated testing suite for continuous integration', + ); + + // Randomly select title length (40% short, 40% medium, 20% long). + $rand = $this->custom_rand( 1, 10 ); + if ( $rand <= 4 ) { + $post_title = $short_titles[ array_rand( $short_titles ) ] . " #{$j}"; + } elseif ( $rand <= 8 ) { + $post_title = $medium_titles[ array_rand( $medium_titles ) ] . " for {$board->name}"; + } else { + $post_title = $long_titles[ array_rand( $long_titles ) ] . " - {$board->name}"; + } + $post_content = "Content for task $j in board {$board->name}."; if ( '1' !== $show_in_boards ) { diff --git a/includes/class-decker-email-to-post.php b/includes/class-decker-email-to-post.php index 853d3374..df99593f 100644 --- a/includes/class-decker-email-to-post.php +++ b/includes/class-decker-email-to-post.php @@ -65,12 +65,12 @@ public function check_permission( $request ) { * @return string. */ private function extract_email( $email ) { - // Verificar si la cadena contiene un formato "Nombre ". + // Check if the string contains a "Name " format. if ( preg_match( '/<([^>]+)>/', $email, $matches ) ) { return sanitize_email( $matches[1] ); } - // Si no hay corchetes, asumir que contiene solo el email. + // If there are no brackets, assume it contains only the email. return sanitize_email( $email ); } @@ -82,23 +82,30 @@ private function extract_email( $email ) { */ public function get_body( Erseco\Message $message ): string { - // Attempt to get the parts of the message. - $parts = $message->getParts(); + // Prefer HTML part. + $html_part = $message->getHtmlPart(); + if ( $html_part ) { + return wp_kses_post( $html_part->getContent() ); + } + + // Fall back to plain text part. + $text_part = $message->getTextPart(); + if ( $text_part ) { + return wp_kses_post( nl2br( esc_html( $text_part->getContent() ) ) ); + } + // Fallback: first part. + $parts = $message->getParts(); if ( count( $parts ) > 0 ) { $content = $parts[0]->getContent(); $content_type = $parts[0]->getContentType(); - if ( str_starts_with( strtolower( $content_type ), 'text/plain;' ) ) { - // Convert plain text to HTML for better readability. + if ( str_starts_with( strtolower( $content_type ), 'text/plain' ) ) { return wp_kses_post( nl2br( esc_html( $content ) ) ); - } else { - // Sanitize and return the HTML content. - return wp_kses_post( $content ); } + return wp_kses_post( $content ); } - // If no parts are available, return an empty string. return ''; } @@ -119,7 +126,10 @@ public function process_email( WP_REST_Request $request ) { try { // Decode the base64-encoded email content. - $raw_email = base64_decode( $payload['rawEmail'] ); + $raw_email = base64_decode( $payload['rawEmail'], true ); + if ( false === $raw_email ) { + return new WP_Error( 'invalid_encoding', 'rawEmail must be base64 encoded', array( 'status' => 400 ) ); + } // Parse email. $message = $this->parse_email( $raw_email ); @@ -216,7 +226,7 @@ private function parse_email( $raw_email ) { * @return int Attachment ID. */ private function upload_attachment( $filename, $content, $type, $post_id ) { - // Verificar permisos y datos necesarios. + // Verify permissions and required data. if ( ! current_user_can( 'upload_files' ) ) { return new WP_Error( 'permission_error', 'No tienes permisos para subir archivos.' ); } @@ -225,21 +235,21 @@ private function upload_attachment( $filename, $content, $type, $post_id ) { return new WP_Error( 'invalid_post', 'ID de post inválido.' ); } - // Extraer solo el tipo MIME sin parámetros adicionales. + // Extract only the MIME type without additional parameters. $type = explode( ';', $type )[0]; - // Crear un nombre de archivo único. + // Create a unique filename. $original_filename = sanitize_file_name( $filename ); $extension = pathinfo( $filename, PATHINFO_EXTENSION ); $upload_dir = wp_upload_dir(); - // Generar nombre único para el archivo usando la función nativa de WordPress. + // Generate a unique file name using the native WordPress function. $obfuscated_name = wp_unique_filename( $upload_dir['path'], wp_generate_uuid4() . '.' . $extension ); - // Construir la ruta completa del archivo. + // Build the full file path. $file_path = $upload_dir['path'] . '/' . $obfuscated_name; // Initialize WordPress Filesystem. @@ -254,17 +264,17 @@ private function upload_attachment( $filename, $content, $type, $post_id ) { return new WP_Error( 'file_write_error', 'Error al escribir el archivo.' ); } - // Preparar el array de información del adjunto. + // Prepare the attachment info array. $attachment = array( 'guid' => $upload_dir['url'] . '/' . $obfuscated_name, 'post_mime_type' => $type, 'post_title' => preg_replace( '/\.[^.]+$/', '', $original_filename ), 'post_content' => '', 'post_status' => 'inherit', - 'post_parent' => $post_id, // Establecer el post parent. + 'post_parent' => $post_id, // Set the post parent. ); - // Insertar el adjunto en la base de datos. + // Insert the attachment into the database. $attachment_id = wp_insert_attachment( $attachment, $file_path, $post_id ); if ( is_wp_error( $attachment_id ) ) { @@ -272,12 +282,12 @@ private function upload_attachment( $filename, $content, $type, $post_id ) { return $attachment_id; } - // Generar metadatos del adjunto. + // Generate attachment metadata. require_once ABSPATH . 'wp-admin/includes/image.php'; $attachment_data = wp_generate_attachment_metadata( $attachment_id, $file_path ); wp_update_attachment_metadata( $attachment_id, $attachment_data ); - // Guardar el nombre original en los metadatos. + // Save the original name in the metadata. update_post_meta( $attachment_id, '_original_filename', $original_filename ); return $attachment_id; diff --git a/includes/class-decker-notification-handler.php b/includes/class-decker-notification-handler.php index 50b57f12..59b95e8c 100644 --- a/includes/class-decker-notification-handler.php +++ b/includes/class-decker-notification-handler.php @@ -301,7 +301,7 @@ public function handle_task_completed( $task_id, $target_stack, $completing_user 'type' => 'task_completed', 'task_id' => $task_id, 'title' => $task->post_title, - /* translators: %s is a username. */ + /* Translators: %s is a username. */ 'action' => sprintf( __( 'Completed by %s', 'decker' ), $finisher ? $finisher->display_name : __( 'Unknown user', 'decker' ) ), 'time' => gmdate( 'Y-m-d H:i:s' ), 'url' => esc_url( $this->build_task_url( $task_id ) ), diff --git a/includes/class-decker-rest-comment-protection.php b/includes/class-decker-rest-comment-protection.php new file mode 100644 index 00000000..4f48266e --- /dev/null +++ b/includes/class-decker-rest-comment-protection.php @@ -0,0 +1,248 @@ +protected_post_types = $this->get_protected_post_types(); + + if ( empty( $this->protected_post_types ) ) { + return; + } + + add_filter( 'rest_comment_query', array( $this, 'prepare_comment_collection_query' ), 10, 2 ); + add_filter( 'rest_pre_dispatch', array( $this, 'protect_single_comment_access' ), 10, 3 ); + add_filter( 'rest_pre_insert_comment', array( $this, 'protect_comment_creation' ), 10, 2 ); + add_filter( 'rest_authentication_errors', array( $this, 'protect_comment_modification' ) ); + } + + /** + * Get the list of protected post types. + * + * @return array The list of protected post types. + */ + private function get_protected_post_types() { + /** + * Filters the list of post types whose comments are protected from unauthenticated access. + * + * @param array $post_types An array of post type slugs. Default: ['decker_task']. + */ + return apply_filters( 'decker/protected_comment_post_types', array( 'decker_task' ) ); + } + + /** + * Prepares to filter the comment collection query for unauthenticated users. + * + * This function hooks into `comments_clauses` only when a REST API request for comments + * is being processed, ensuring the filter is not applied globally. + * + * @param array $args Request arguments. + * @param WP_REST_Request $request The request object. + * @return array The original arguments. + */ + public function prepare_comment_collection_query( $args, $request ) { + if ( ! is_user_logged_in() ) { + add_filter( 'comments_clauses', array( $this, 'filter_comment_collection_query' ) ); + } + return $args; + } + + /** + * Exclude comments from protected post types in collection queries for unauthenticated users. + * + * This function is hooked dynamically by `prepare_comment_collection_query`. + * + * @param array $clauses The clauses for the comments query. + * @return array The modified clauses. + */ + public function filter_comment_collection_query( $clauses ) { + // Remove the filter immediately to prevent it from affecting other queries. + remove_filter( 'comments_clauses', array( $this, 'filter_comment_collection_query' ) ); + + global $wpdb; + + // Add a JOIN clause to link comments to the posts table. + $clauses['join'] .= " LEFT JOIN {$wpdb->posts} p ON p.ID = {$wpdb->comments}.comment_post_ID"; + + // Add a WHERE clause to exclude comments from protected post types. + $where_clause = $wpdb->prepare( + ' AND (p.post_type IS NULL OR p.post_type NOT IN (' . implode( ', ', array_fill( 0, count( $this->protected_post_types ), '%s' ) ) . '))', + $this->protected_post_types + ); + + // Also check for comments without a parent post (comment_post_ID = 0) and allow them. + $clauses['where'] .= " AND ({$wpdb->comments}.comment_post_ID = 0 OR " . substr( trim( $where_clause ), 4 ) . ')'; + + return $clauses; + } + + /** + * Block access to single comments on protected post types for unauthenticated users. + * + * @param mixed $result Dispatch result, will be used if not null. + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + * @return mixed A WP_Error if access is denied, otherwise the original $result. + */ + public function protect_single_comment_access( $result, $server, $request ) { + if ( is_user_logged_in() ) { + return $result; + } + + $route = $request->get_route(); + $method = strtoupper( $request->get_method() ); + + // Block unauthenticated creation on protected post types. + if ( 0 === strpos( $route, '/wp/v2/comments' ) && 'POST' === $method ) { + $post_id = (int) $request->get_param( 'post' ); + if ( $post_id && in_array( get_post_type( $post_id ), $this->protected_post_types, true ) ) { + return new WP_Error( + 'rest_cannot_create_comment', + __( 'You are not authorized to access this resource.', 'decker' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + return $result; + } + + // Handle single comment routes. + if ( preg_match( '#^/wp/v2/comments/(?P\d+)#', $route, $matches ) ) { + $comment_id = (int) $matches['id']; + $comment = get_comment( $comment_id ); + + if ( ! $comment ) { + return $result; + } + + $is_protected = in_array( get_post_type( $comment->comment_post_ID ), $this->protected_post_types, true ); + if ( ! $is_protected ) { + return $result; + } + + if ( 'GET' === $method ) { + return new WP_Error( + 'rest_forbidden_comment', + __( 'You are not authorized to access this resource.', 'decker' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( in_array( $method, array( 'PUT', 'PATCH', 'DELETE' ), true ) ) { + return new WP_Error( + 'rest_cannot_edit_comment', + __( 'You are not authorized to access this resource.', 'decker' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + return $result; + } + + /** + * Prevent unauthenticated users from creating comments on protected post types. + * + * @param array|WP_Error $prepared_comment An array of comment data or a WP_Error. + * @param WP_REST_Request $request The request object. + * @return array|WP_Error The comment data or a WP_Error if denied. + */ + public function protect_comment_creation( $prepared_comment, $request ) { + if ( is_user_logged_in() || is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $post_id = (int) $request['post']; + if ( $post_id && in_array( get_post_type( $post_id ), $this->protected_post_types, true ) ) { + return new WP_Error( + 'rest_cannot_create_comment', + __( 'You are not authorized to access this resource.', 'decker' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return $prepared_comment; + } + + /** + * Prevent unauthenticated users from updating or deleting comments on protected post types. + * + * @param WP_Error|null|true $result WP_Error if authentication error, null if authentication method wasn't used, true if authentication succeeded. + * @return WP_Error|null|true + */ + public function protect_comment_modification( $result ) { + // Let existing errors through, and allow authenticated users. + if ( is_user_logged_in() || is_wp_error( $result ) ) { + return $result; + } + + // This hook runs on all authenticated REST requests. We must check if this is a comment modification request. + $request_uri = ! empty( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + if ( ! preg_match( '#/wp/v2/comments/(?P\d+)#', $request_uri, $matches ) ) { + return $result; + } + + $request_method = ! empty( $_SERVER['REQUEST_METHOD'] ) + ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) + : ''; + if ( ! in_array( $request_method, array( 'PUT', 'PATCH', 'DELETE' ), true ) ) { + return $result; + } + + $comment_id = (int) $matches['id']; + $comment = get_comment( $comment_id ); + + if ( $comment && in_array( get_post_type( $comment->comment_post_ID ), $this->protected_post_types, true ) ) { + return new WP_Error( + 'rest_cannot_edit_comment', + __( 'You are not authorized to access this resource.', 'decker' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return $result; + } +} + +// Instantiate the protection class to ensure hooks are registered during REST requests. +if ( class_exists( 'Decker_REST_Comment_Protection' ) ) { + new Decker_REST_Comment_Protection(); +} diff --git a/includes/class-decker-wpcli.php b/includes/class-decker-wpcli.php index 2a3008fb..f651a5dd 100644 --- a/includes/class-decker-wpcli.php +++ b/includes/class-decker-wpcli.php @@ -58,6 +58,6 @@ public function create_sample_data() { } } - // Registrar el comando principal que agrupa los subcomandos. + // Register the main command that groups the subcommands. WP_CLI::add_command( 'decker', 'Decker_WPCLI' ); } diff --git a/includes/class-decker.php b/includes/class-decker.php index a0084d52..0b27516a 100644 --- a/includes/class-decker.php +++ b/includes/class-decker.php @@ -119,6 +119,11 @@ private function load_dependencies() { require_once plugin_dir_path( __DIR__ ) . 'includes/class-decker-disable-comment-notifications.php'; + /** + * The class responsible for protecting comments on custom post types via the REST API. + */ + require_once plugin_dir_path( __DIR__ ) . 'includes/class-decker-rest-comment-protection.php'; + /** * The class responsible for defining the MVC. */ @@ -130,6 +135,14 @@ private function load_dependencies() { require_once plugin_dir_path( __DIR__ ) . 'includes/models/class-labelmanager.php'; require_once plugin_dir_path( __DIR__ ) . 'includes/models/class-taskmanager.php'; + /** + * The class responsible for network-level settings in Multisite. + */ + require_once plugin_dir_path( __DIR__ ) . 'admin/class-decker-network-settings.php'; + if ( is_multisite() ) { + new Decker_Network_Settings(); + } + /** * The class responsible for defining all actions that occur in the admin area. */ diff --git a/includes/custom-post-types/class-decker-events.php b/includes/custom-post-types/class-decker-events.php index 4a0a9422..2578efe7 100644 --- a/includes/custom-post-types/class-decker-events.php +++ b/includes/custom-post-types/class-decker-events.php @@ -224,7 +224,7 @@ public function restrict_rest_access( $result, $rest_server, $request ) { $route = $request->get_route(); if ( strpos( $route, '/wp/v2/decker_event' ) === 0 ) { - // Usa la capacidad específica del CPT. + // Use the specific capability of the CPT. if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'rest_forbidden', @@ -242,7 +242,7 @@ public function restrict_rest_access( $result, $rest_server, $request ) { * * - Raw date (YYYY‑MM‑DD) → treat as all‑day, keep it as is. * - ISO‑8601 UTC with “Z” → keep as is. - * - Anything else → normaliza a 'Y-m-d H:i:s' (UTC). + * - Anything else → normalize to 'Y-m-d H:i:s' (UTC). * * @param string $value Raw input. * @param string $meta_key Meta key (unused, needed for callback signature). @@ -278,13 +278,13 @@ public static function sanitize_event_datetime( $value, $meta_key = '', $object_ } /** - * Normaliza los metadatos de fecha tras una operación REST. + * Normalize date metadata after a REST operation. * - * – Para all‑day: corta la hora. - * – Para eventos con hora: - * · Convierte ISO (‘T…Z’) a ‘Y-m-d H:i:s’. - * · Si llega solo la fecha añade ‘00:00:00’. - * · Si end ≤ start, ajusta end = start + 1h. + * – For all-day: trim the time. + * – For timed events: + * · Convert ISO ('T…Z') to 'Y-m-d H:i:s'. + * · If only the date arrives, add '00:00:00'. + * · If end ≤ start, adjust end = start + 1h. * * @param WP_Post $post the post. * @param WP_REST_Request $request the request. @@ -316,7 +316,7 @@ public function rest_fix_datetime_meta( $post, $request, $update ) { ? str_replace( array( 'T', 'Z' ), array( ' ', '' ), $$var ) : $$var; - // b) solo fecha → medianoche. + // b) date only → midnight. if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $$var ) ) { $$var .= ' 00:00:00'; } @@ -324,7 +324,7 @@ public function rest_fix_datetime_meta( $post, $request, $update ) { update_post_meta( $post->ID, $meta, $$var ); } - // c) end vacío o ≤ start → +1h. + // c) end empty or ≤ start → +1h. if ( ! $end || strtotime( $end ) <= strtotime( $start ) ) { $end = gmdate( 'Y-m-d H:i:s', strtotime( $start ) + HOUR_IN_SECONDS ); update_post_meta( $post->ID, 'event_end', $end ); @@ -383,7 +383,7 @@ public function render_event_details_meta_box( $post ) { $end_for_input = str_replace( ' ', 'T', $end_utc ); } - $step_attr = $allday ? '' : ' step="60s"'; // 60s ⇒ oculta segundos. + $step_attr = $allday ? '' : ' step="60s"'; // 60s ⇒ hides seconds. $value_attr = $allday ? esc_attr( $start_utc ) : esc_attr( gmdate( 'Y-m-d\TH:i', strtotime( $start_utc . ' UTC' ) ) ); @@ -396,12 +396,12 @@ public function render_event_details_meta_box( $post ) {

- + - + diff --git a/public/layouts/kb-view-modal.php b/public/layouts/kb-view-modal.php index c5516605..95902d2c 100644 --- a/public/layouts/kb-view-modal.php +++ b/public/layouts/kb-view-modal.php @@ -12,7 +12,7 @@ ?>