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' ) . '
+ 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 ) {
-
+