diff --git a/changelog.txt b/changelog.txt index 93051f970..8d474de51 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,14 @@ == Changelog == += v3.0.3 – Mar 4, 2026 = +**Fixed:** Resolved an issue where email notifications were not being sent. +**Fixed:** Improved button width handling in the list view. +**Fixed:** Corrected Slovak and other Unicode character capitalization in the `ucfirst` function. +**Fixed:** Automatically infer `link_to_backend` from pages when it is not set. +**Added:** Added support for the `view-all-projects` check and implemented it in the controller. +**Fixed:** Restored missing labels in the tasks list. +**Added:** Feature headway-integration. + = v3.0.2 – Jan 14, 2026 = **Fixed:** Subscriber Level Authorization issue [#573](https://github.com/weDevsOfficial/wp-project-manager/pull/573). diff --git a/cpm.php b/cpm.php index b7c75712a..4add46e50 100644 --- a/cpm.php +++ b/cpm.php @@ -5,7 +5,7 @@ * Description: WordPress Project Management plugin. Manage your projects and tasks, get things done. * Author: weDevs * Author URI: https://wedevs.com - * Version: 3.0.2 + * Version: 3.0.3 * Text Domain: wedevs-project-manager * Domain Path: /languages * License: GPL2 @@ -16,7 +16,7 @@ exit; } // Define version directly to avoid early translation loading from get_plugin_data() -define('PM_VERSION', '3.0.2'); +define('PM_VERSION', '3.0.3'); require __DIR__.'/bootstrap/loaders.php'; require __DIR__.'/libs/configurations.php'; diff --git a/package-lock.json b/package-lock.json index 13e139af4..d7aef9055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "pmapi", - "version": "2.6.29", + "version": "3.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 36d267cee..28975b127 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pmapi", - "version": "3.0.1", + "version": "3.0.3", "description": "Front-end package manager for project manager", "main": "index.js", "directories": { @@ -9,7 +9,7 @@ "scripts": { "start": "webpack --progress --colors --watch", "build": "cross-env NODE_ENV=production webpack --progress --colors ", - "release": "webpack --progress --colors grunt zip", + "release": "grunt release --force", "makepot": "wpvuei18n makepot" }, "repository": { diff --git a/readme.txt b/readme.txt index e7237e470..5704427c7 100644 --- a/readme.txt +++ b/readme.txt @@ -4,9 +4,9 @@ Contributors: tareq1988, nizamuddinbabu, wedevs, asaquzzaman Donate Link: https://tareq.co/donate/ Tags: kanban, project, project management, task management, project manager Requires at least: 6.2 -Tested up to: 6.9 +Tested up to: 6.9.1 Requires PHP: 7.4 -Stable tag: 3.0.2 +Stable tag: 3.0.3 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -241,6 +241,15 @@ A. If you face any issues, you can contact the support team through the official == Changelog == += v3.0.3 – Mar 4, 2026 = +**Fixed:** Resolved an issue where email notifications were not being sent. +**Fixed:** Improved button width handling in the list view. +**Fixed:** Corrected Slovak and other Unicode character capitalization in the `ucfirst` function. +**Fixed:** Automatically infer `link_to_backend` from pages when it is not set. +**Added:** Added support for the `view-all-projects` check and implemented it in the controller. +**Fixed:** Restored missing labels in the tasks list. +**Added:** Feature headway-integration. + = v3.0.2 – Jan 14, 2026 = **Fixed:** Subscriber Level Authorization issue [#573](https://github.com/weDevsOfficial/wp-project-manager/pull/573). diff --git a/routes/github.php b/routes/github.php new file mode 100644 index 000000000..2d05dde96 --- /dev/null +++ b/routes/github.php @@ -0,0 +1,18 @@ +post( 'github/preview', 'WeDevs/PM/GitHub/Controllers/GitHub_Preview_Controller@preview' ) + ->permission( [ $wedevs_pm_authentic ] ); + +// Batch GitHub URLs preview +$wedevs_pm_router->post( 'github/batch-preview', 'WeDevs/PM/GitHub/Controllers/GitHub_Preview_Controller@batch_preview' ) + ->permission( [ $wedevs_pm_authentic ] ); + +// Test GitHub connection +$wedevs_pm_router->post( 'github/test-connection', 'WeDevs/PM/GitHub/Controllers/GitHub_Preview_Controller@test_connection' ) + ->permission( [ 'WeDevs\PM\Core\Permissions\Settings_Page_Access' ] ); diff --git a/routes/loom.php b/routes/loom.php new file mode 100644 index 000000000..837f4c6b6 --- /dev/null +++ b/routes/loom.php @@ -0,0 +1,18 @@ +post( 'loom/preview', 'WeDevs/PM/Loom/Controllers/Loom_Preview_Controller@preview' ) + ->permission( [ $wedevs_pm_authentic ] ); + +// Batch Loom URLs preview +$wedevs_pm_router->post( 'loom/batch-preview', 'WeDevs/PM/Loom/Controllers/Loom_Preview_Controller@batch_preview' ) + ->permission( [ $wedevs_pm_authentic ] ); + +// Test Loom connection +$wedevs_pm_router->post( 'loom/test-connection', 'WeDevs/PM/Loom/Controllers/Loom_Preview_Controller@test_connection' ) + ->permission( [ 'WeDevs\PM\Core\Permissions\Settings_Page_Access' ] ); diff --git a/routes/notion.php b/routes/notion.php new file mode 100644 index 000000000..200b8d023 --- /dev/null +++ b/routes/notion.php @@ -0,0 +1,18 @@ +post( 'notion/preview', 'WeDevs/PM/Notion/Controllers/Notion_Preview_Controller@preview' ) + ->permission( [ $wedevs_pm_authentic ] ); + +// Batch Notion URLs preview +$wedevs_pm_router->post( 'notion/batch-preview', 'WeDevs/PM/Notion/Controllers/Notion_Preview_Controller@batch_preview' ) + ->permission( [ $wedevs_pm_authentic ] ); + +// Test Notion connection +$wedevs_pm_router->post( 'notion/test-connection', 'WeDevs/PM/Notion/Controllers/Notion_Preview_Controller@test_connection' ) + ->permission( [ 'WeDevs\PM\Core\Permissions\Settings_Page_Access' ] ); diff --git a/src/GitHub/Controllers/GitHub_Preview_Controller.php b/src/GitHub/Controllers/GitHub_Preview_Controller.php new file mode 100644 index 000000000..6220353d6 --- /dev/null +++ b/src/GitHub/Controllers/GitHub_Preview_Controller.php @@ -0,0 +1,473 @@ +whereNull( 'project_id' ) + ->first(); + + if ( $setting && ! empty( $setting->value ) ) { + return $setting->value; + } + + return false; + } + + /** + * Check if GitHub previews are enabled. + * + * @return bool + */ + private function is_previews_enabled() { + $setting = Settings::where( 'key', 'github_enable_previews' ) + ->whereNull( 'project_id' ) + ->first(); + + if ( $setting ) { + return ! in_array( $setting->value, [ false, 'false', '0', 0 ], true ); + } + + // Enabled by default + return true; + } + + /** + * Build HTTP headers for GitHub API requests. + * + * @param string|false $token Optional token override. + * @return array + */ + private function get_api_headers( $token = null ) { + $headers = [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'WordPress-PM-Plugin', + ]; + + if ( $token === null ) { + $token = $this->get_saved_token(); + } + + if ( $token && $token !== false ) { + $headers['Authorization'] = 'token ' . $token; + } + + return $headers; + } + + /** + * Test GitHub connection with the provided or saved token. + * + * @param WP_REST_Request $request + * @return array + */ + public function test_connection( WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + + // If token is '__saved__', use the saved token + if ( $token === '__saved__' ) { + $token = $this->get_saved_token(); + + if ( ! $token ) { + return [ + 'success' => false, + 'error' => __( 'No token saved. Please enter a GitHub Personal Access Token and save settings first.', 'wedevs-project-manager' ), + ]; + } + } + + $headers = $this->get_api_headers( $token ?: false ); + + // Test with the /user endpoint (requires token) or /rate_limit (works without) + if ( $token && ! empty( $token ) ) { + $api_url = 'https://api.github.com/user'; + } else { + $api_url = 'https://api.github.com/rate_limit'; + } + + $response = wp_remote_get( $api_url, [ + 'timeout' => 15, + 'headers' => $headers, + 'sslverify' => true, + ] ); + + if ( is_wp_error( $response ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'GitHub API connection error: ' . $response->get_error_message() ); + } + + return [ + 'success' => false, + 'error' => __( 'Could not connect to GitHub. Please check your network and try again.', 'wedevs-project-manager' ), + ]; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status_code === 401 ) { + return [ + 'success' => false, + 'error' => __( 'Invalid token. Please check your GitHub Personal Access Token.', 'wedevs-project-manager' ), + ]; + } + + if ( $status_code !== 200 ) { + return [ + 'success' => false, + 'error' => sprintf( + __( 'GitHub API returned status %d.', 'wedevs-project-manager' ), + $status_code + ), + ]; + } + + // Get rate limit info from headers + $rate_limit_remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' ); + $rate_limit_total = wp_remote_retrieve_header( $response, 'x-ratelimit-limit' ); + + $result = [ + 'success' => true, + 'data' => [ + 'login' => isset( $body['login'] ) ? sanitize_text_field( $body['login'] ) : '', + 'rate_limit' => [ + 'remaining' => intval( $rate_limit_remaining ), + 'limit' => intval( $rate_limit_total ), + ], + ], + ]; + + if ( ! $token || empty( $token ) ) { + $result['data']['login'] = __( 'Anonymous (no token)', 'wedevs-project-manager' ); + } + + return $result; + } + + /** + * Fetch preview data for a GitHub issue or pull request URL. + * + * @param WP_REST_Request $request + * @return array + */ + public function preview( WP_REST_Request $request ) { + if ( ! $this->is_previews_enabled() ) { + return [ + 'success' => false, + 'error' => __( 'GitHub previews are disabled.', 'wedevs-project-manager' ), + ]; + } + + $url = $request->get_param( 'url' ); + + if ( ! is_string( $url ) || empty( $url ) ) { + return [ + 'success' => false, + 'error' => __( 'URL is required.', 'wedevs-project-manager' ), + ]; + } + + $parsed = $this->parse_github_url( $url ); + + if ( ! $parsed ) { + return [ + 'success' => false, + 'error' => __( 'Invalid GitHub issue or pull request URL.', 'wedevs-project-manager' ), + ]; + } + + $force_refresh = filter_var( $request->get_param( 'force_refresh' ), FILTER_VALIDATE_BOOLEAN ); + + return $this->fetch_github_data( $parsed, $force_refresh ); + } + + /** + * Fetch preview data for multiple GitHub URLs at once. + * + * @param WP_REST_Request $request + * @return array + */ + public function batch_preview( WP_REST_Request $request ) { + if ( ! $this->is_previews_enabled() ) { + return [ + 'success' => false, + 'error' => __( 'GitHub previews are disabled.', 'wedevs-project-manager' ), + ]; + } + + $urls = $request->get_param( 'urls' ); + + if ( empty( $urls ) || ! is_array( $urls ) ) { + return [ + 'success' => false, + 'error' => __( 'URLs array is required.', 'wedevs-project-manager' ), + ]; + } + + // Limit batch size to 10 + $urls = array_slice( $urls, 0, 10 ); + $results = []; + + foreach ( $urls as $url ) { + $parsed = $this->parse_github_url( $url ); + + if ( ! $parsed ) { + $results[ $url ] = [ + 'success' => false, + 'error' => __( 'Invalid GitHub URL.', 'wedevs-project-manager' ), + ]; + continue; + } + + $results[ $url ] = $this->fetch_github_data( $parsed ); + } + + return [ + 'success' => true, + 'data' => $results, + ]; + } + + /** + * Parse a GitHub URL into its components. + * + * @param string $url + * @return array|false + */ + private function parse_github_url( $url ) { + $url = esc_url_raw( trim( $url ) ); + + $pattern = '#https?://github\.com/([^/]+)/([^/]+)/(issues|pull)/(\d+)#i'; + + if ( preg_match( $pattern, $url, $matches ) ) { + return [ + 'owner' => sanitize_text_field( $matches[1] ), + 'repo' => sanitize_text_field( $matches[2] ), + 'type' => strtolower( $matches[3] ) === 'pull' ? 'pull_request' : 'issue', + 'number' => intval( $matches[4] ), + 'url' => $url, + ]; + } + + return false; + } + + /** + * Fetch data from the GitHub API with transient caching. + * + * @param array $parsed Parsed URL components. + * @param bool $force_refresh Whether to bypass cache. + * @return array + */ + private function fetch_github_data( $parsed, $force_refresh = false ) { + $cache_key = 'pm_github_preview_' . md5( $parsed['url'] ); + + if ( $force_refresh ) { + delete_transient( $cache_key ); + } + + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $owner = $parsed['owner']; + $repo = $parsed['repo']; + $number = $parsed['number']; + $type = $parsed['type']; + + // Build GitHub API URL + if ( $type === 'pull_request' ) { + $api_url = sprintf( + 'https://api.github.com/repos/%s/%s/pulls/%d', + rawurlencode( $owner ), rawurlencode( $repo ), $number + ); + } else { + $api_url = sprintf( + 'https://api.github.com/repos/%s/%s/issues/%d', + rawurlencode( $owner ), rawurlencode( $repo ), $number + ); + } + + $response = wp_remote_get( $api_url, [ + 'timeout' => 10, + 'headers' => $this->get_api_headers(), + 'sslverify' => true, + ] ); + + if ( is_wp_error( $response ) ) { + $error_result = [ + 'success' => true, + 'data' => [ + 'type' => $type, + 'number' => $number, + 'state' => 'error', + 'repository' => [ + 'owner' => $owner, + 'name' => $repo, + 'full_name' => $owner . '/' . $repo, + ], + 'error' => __( 'Could not connect to GitHub.', 'wedevs-project-manager' ), + 'html_url' => $parsed['url'], + ], + ]; + + set_transient( $cache_key, $error_result, 2 * MINUTE_IN_SECONDS ); + + return $error_result; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + $body = null; + } + + // Handle rate limiting (check before access-denied because GitHub returns + // 403 for rate limit exhaustion, not just 429) + $is_rate_limited = $status_code === 429 + || ( $status_code === 403 && wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' ) === '0' ); + + if ( $is_rate_limited ) { + $result = [ + 'success' => true, + 'data' => [ + 'type' => $type, + 'number' => $number, + 'state' => 'rate_limited', + 'repository' => [ + 'owner' => $owner, + 'name' => $repo, + 'full_name' => $owner . '/' . $repo, + ], + 'error' => __( 'GitHub API rate limit exceeded. Please try again later.', 'wedevs-project-manager' ), + 'html_url' => $parsed['url'], + ], + ]; + + set_transient( $cache_key, $result, 10 * MINUTE_IN_SECONDS ); + + return $result; + } + + // Handle access denied / not found + if ( $status_code === 403 || $status_code === 404 || $status_code === 401 ) { + $result = [ + 'success' => true, + 'data' => [ + 'type' => $type, + 'number' => $number, + 'state' => 'access_denied', + 'repository' => [ + 'owner' => $owner, + 'name' => $repo, + 'full_name' => $owner . '/' . $repo, + ], + 'error' => __( 'Access denied', 'wedevs-project-manager' ), + 'html_url' => $parsed['url'], + ], + ]; + + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + + return $result; + } + + // Handle unexpected status codes + if ( $status_code !== 200 || empty( $body ) ) { + $result = [ + 'success' => true, + 'data' => [ + 'type' => $type, + 'number' => $number, + 'state' => 'error', + 'repository' => [ + 'owner' => $owner, + 'name' => $repo, + 'full_name' => $owner . '/' . $repo, + ], + 'error' => __( 'Unable to fetch data from GitHub.', 'wedevs-project-manager' ), + 'html_url' => $parsed['url'], + ], + ]; + + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + + return $result; + } + + // Determine state for PRs (can be merged) + $state = isset( $body['state'] ) ? sanitize_text_field( $body['state'] ) : 'unknown'; + if ( $type === 'pull_request' && $state === 'closed' && ! empty( $body['merged'] ) ) { + $state = 'merged'; + } + + // Build labels array + $labels = []; + if ( ! empty( $body['labels'] ) && is_array( $body['labels'] ) ) { + foreach ( $body['labels'] as $label ) { + $labels[] = [ + 'name' => sanitize_text_field( $label['name'] ), + 'color' => sanitize_hex_color_no_hash( $label['color'] ), + ]; + } + } + + // Build assignees array + $assignees = []; + if ( ! empty( $body['assignees'] ) && is_array( $body['assignees'] ) ) { + foreach ( $body['assignees'] as $assignee ) { + $assignees[] = [ + 'login' => sanitize_text_field( $assignee['login'] ), + 'avatar_url' => esc_url_raw( $assignee['avatar_url'] ), + ]; + } + } + + $result = [ + 'success' => true, + 'data' => [ + 'type' => $type, + 'number' => $number, + 'title' => sanitize_text_field( $body['title'] ), + 'state' => $state, + 'repository' => [ + 'owner' => $owner, + 'name' => $repo, + 'full_name' => $owner . '/' . $repo, + ], + 'author' => [ + 'login' => isset( $body['user']['login'] ) ? sanitize_text_field( $body['user']['login'] ) : '', + 'avatar_url' => isset( $body['user']['avatar_url'] ) ? esc_url_raw( $body['user']['avatar_url'] ) : '', + ], + 'labels' => $labels, + 'assignees' => $assignees, + 'created_at' => isset( $body['created_at'] ) ? sanitize_text_field( $body['created_at'] ) : '', + 'html_url' => esc_url_raw( $body['html_url'] ), + ], + ]; + + // Cache successful responses for 1 hour + set_transient( $cache_key, $result, HOUR_IN_SECONDS ); + + return $result; + } +} diff --git a/src/Loom/Controllers/Loom_Preview_Controller.php b/src/Loom/Controllers/Loom_Preview_Controller.php new file mode 100644 index 000000000..1003e2e3b --- /dev/null +++ b/src/Loom/Controllers/Loom_Preview_Controller.php @@ -0,0 +1,405 @@ +whereNull( 'project_id' ) + ->first(); + + if ( $setting ) { + return ! in_array( $setting->value, [ false, 'false', '0', 0 ], true ); + } + + return true; + } + + /** + * Test Loom oEmbed connection. + * + * @param WP_REST_Request $request + * @return array + */ + public function test_connection( WP_REST_Request $request ) { + $oembed_url = 'https://www.loom.com/v1/oembed?url=' . urlencode( 'https://www.loom.com/share/test' ); + + $ssl_verify = ! ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV ); + + $response = wp_remote_get( $oembed_url, [ + 'timeout' => 15, + 'headers' => [ + 'User-Agent' => 'WordPress/' . get_bloginfo( 'version' ), + 'Accept' => 'application/json', + ], + 'sslverify' => $ssl_verify, + ] ); + + if ( is_wp_error( $response ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Loom oEmbed connection error: ' . $response->get_error_message() ); + } + + return [ + 'success' => false, + 'error' => __( 'Could not connect to Loom. Please check your network and try again.', 'wedevs-project-manager' ), + ]; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + + // oEmbed returns 404 for invalid video IDs but the endpoint is reachable + // A 5xx response means the service is down + if ( $status_code >= 500 ) { + return [ + 'success' => false, + 'error' => sprintf( + __( 'Loom API returned status %d.', 'wedevs-project-manager' ), + $status_code + ), + ]; + } + + return [ + 'success' => true, + 'data' => [ + 'oembed_available' => true, + ], + ]; + } + + /** + * Fetch preview data for a single Loom video URL. + * + * @param WP_REST_Request $request + * @return array + */ + public function preview( WP_REST_Request $request ) { + if ( ! $this->is_previews_enabled() ) { + return [ + 'success' => false, + 'error' => __( 'Loom previews are disabled.', 'wedevs-project-manager' ), + ]; + } + + $url = $request->get_param( 'url' ); + + if ( ! is_string( $url ) || empty( $url ) ) { + return [ + 'success' => false, + 'error' => __( 'URL is required.', 'wedevs-project-manager' ), + ]; + } + + $parsed = $this->parse_loom_url( $url ); + + if ( ! $parsed ) { + return [ + 'success' => false, + 'error' => __( 'Invalid Loom URL.', 'wedevs-project-manager' ), + ]; + } + + $force_refresh = filter_var( $request->get_param( 'force_refresh' ), FILTER_VALIDATE_BOOLEAN ); + + return $this->fetch_loom_data( $parsed, $force_refresh ); + } + + /** + * Fetch preview data for multiple Loom URLs at once. + * + * @param WP_REST_Request $request + * @return array + */ + public function batch_preview( WP_REST_Request $request ) { + if ( ! $this->is_previews_enabled() ) { + return [ + 'success' => false, + 'error' => __( 'Loom previews are disabled.', 'wedevs-project-manager' ), + ]; + } + + $urls = $request->get_param( 'urls' ); + + if ( empty( $urls ) || ! is_array( $urls ) ) { + return [ + 'success' => false, + 'error' => __( 'URLs array is required.', 'wedevs-project-manager' ), + ]; + } + + $urls = array_slice( $urls, 0, 10 ); + $results = []; + + foreach ( $urls as $url ) { + if ( ! is_string( $url ) || empty( $url ) ) { + continue; + } + + $parsed = $this->parse_loom_url( $url ); + + if ( ! $parsed ) { + $results[ $url ] = [ + 'success' => false, + 'error' => __( 'Invalid Loom URL.', 'wedevs-project-manager' ), + ]; + continue; + } + + $results[ $url ] = $this->fetch_loom_data( $parsed, false ); + } + + return [ + 'success' => true, + 'data' => $results, + ]; + } + + /** + * Parse a Loom URL into its components. + * + * Supported URL patterns: + * - https://www.loom.com/share/{video_id} + * - https://www.loom.com/embed/{video_id} + * - https://loom.com/share/{video_id} + * + * @param string $url + * @return array|false + */ + private function parse_loom_url( $url ) { + $url = esc_url_raw( trim( $url ) ); + + // Validate domain + if ( ! preg_match( '/^https?:\/\/(?:www\.)?loom\.com\//i', $url ) ) { + return false; + } + + // Extract video ID from share or embed path (32-char hex) + if ( ! preg_match( '/^https?:\/\/(?:www\.)?loom\.com\/(share|embed)\/([a-f0-9]{32})(?:[?#\/]|$)/i', $url, $matches ) ) { + return false; + } + + $video_id = sanitize_text_field( $matches[2] ); + $type = sanitize_text_field( $matches[1] ); + + if ( empty( $video_id ) ) { + return false; + } + + // Normalize URL to share format + $share_url = sprintf( 'https://www.loom.com/share/%s', rawurlencode( $video_id ) ); + + return [ + 'video_id' => $video_id, + 'type' => $type, + 'url' => $url, + 'share_url' => $share_url, + ]; + } + + /** + * Fetch data from Loom oEmbed API with transient caching. + * + * @param array $parsed Parsed URL components. + * @param bool $force_refresh Whether to bypass cache. + * @return array + */ + private function fetch_loom_data( $parsed, $force_refresh = false ) { + $cache_key = 'pm_loom_preview_' . md5( $parsed['url'] ); + + if ( $force_refresh ) { + delete_transient( $cache_key ); + } + + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $result = $this->try_oembed( $parsed ); + + if ( $result === null ) { + $result = $this->build_error_result( + $parsed['video_id'], + $parsed['url'], + __( 'Could not fetch video data from Loom.', 'wedevs-project-manager' ) + ); + + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + + return $result; + } + + // Cache based on state + if ( isset( $result['data']['state'] ) && $result['data']['state'] === 'success' ) { + set_transient( $cache_key, $result, HOUR_IN_SECONDS ); + } elseif ( isset( $result['data']['state'] ) && $result['data']['state'] === 'rate_limited' ) { + set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS ); + } else { + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + } + + return $result; + } + + /** + * Try fetching video data via Loom oEmbed API. + * + * @param array $parsed + * @return array|null + */ + private function try_oembed( $parsed ) { + $oembed_url = 'https://www.loom.com/v1/oembed?url=' . urlencode( $parsed['share_url'] ); + + $ssl_verify = ! ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV ); + + $response = wp_remote_get( $oembed_url, [ + 'timeout' => 15, + 'headers' => [ + 'User-Agent' => 'WordPress/' . get_bloginfo( 'version' ), + 'Accept' => 'application/json', + ], + 'sslverify' => $ssl_verify, + ] ); + + if ( is_wp_error( $response ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Loom oEmbed error for ' . $oembed_url . ': ' . $response->get_error_message() ); + } + + return $this->build_error_result( + $parsed['video_id'], + $parsed['url'], + __( 'Could not connect to Loom.', 'wedevs-project-manager' ) + ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $raw_body = wp_remote_retrieve_body( $response ); + $body = json_decode( $raw_body, true ); + + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Loom oEmbed status: ' . $status_code . ' for ' . $oembed_url ); + error_log( 'Loom oEmbed body length: ' . strlen( $raw_body ) ); + } + + if ( json_last_error() !== JSON_ERROR_NONE ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Loom oEmbed JSON error: ' . json_last_error_msg() ); + } + $body = null; + } + + if ( $status_code === 404 ) { + return $this->build_error_result( + $parsed['video_id'], + $parsed['url'], + __( 'Video not found or is private.', 'wedevs-project-manager' ), + 'access_denied' + ); + } + + if ( $status_code === 401 || $status_code === 403 ) { + return [ + 'success' => true, + 'data' => [ + 'type' => 'video', + 'video_id' => $parsed['video_id'], + 'state' => 'access_denied', + 'error' => __( 'Video is private or access denied.', 'wedevs-project-manager' ), + 'url' => $parsed['url'], + ], + ]; + } + + if ( $status_code === 429 ) { + return $this->build_error_result( + $parsed['video_id'], + $parsed['url'], + __( 'Loom API rate limit exceeded. Please try again later.', 'wedevs-project-manager' ), + 'rate_limited' + ); + } + + if ( $status_code !== 200 || empty( $body ) ) { + return $this->build_error_result( + $parsed['video_id'], + $parsed['url'], + __( 'Unable to fetch video data from Loom.', 'wedevs-project-manager' ) + ); + } + + // Extract data from oEmbed response + $title = isset( $body['title'] ) ? sanitize_text_field( $body['title'] ) : __( 'Untitled Video', 'wedevs-project-manager' ); + $author_name = isset( $body['author_name'] ) ? sanitize_text_field( $body['author_name'] ) : ''; + $description = isset( $body['description'] ) ? sanitize_text_field( $body['description'] ) : ''; + $thumbnail_url = isset( $body['thumbnail_url'] ) ? esc_url_raw( $body['thumbnail_url'] ) : ''; + $duration = isset( $body['duration'] ) ? floatval( $body['duration'] ) : 0; + $provider_name = isset( $body['provider_name'] ) ? sanitize_text_field( $body['provider_name'] ) : 'Loom'; + + // Extract dimensions + $thumbnail_width = isset( $body['thumbnail_width'] ) ? intval( $body['thumbnail_width'] ) : 0; + $thumbnail_height = isset( $body['thumbnail_height'] ) ? intval( $body['thumbnail_height'] ) : 0; + $width = isset( $body['width'] ) ? intval( $body['width'] ) : 0; + $height = isset( $body['height'] ) ? intval( $body['height'] ) : 0; + + return [ + 'success' => true, + 'data' => [ + 'type' => 'video', + 'video_id' => sanitize_text_field( $parsed['video_id'] ), + 'title' => $title, + 'author_name' => $author_name, + 'description' => $description, + 'thumbnail_url' => $thumbnail_url, + 'thumbnail_width' => $thumbnail_width, + 'thumbnail_height' => $thumbnail_height, + 'width' => $width, + 'height' => $height, + 'duration' => $duration, + 'provider_name' => $provider_name, + 'url' => $parsed['url'], + 'embed_url' => sprintf( 'https://www.loom.com/embed/%s', rawurlencode( $parsed['video_id'] ) ), + 'state' => 'success', + ], + ]; + } + + /** + * Build a standard error result array. + * + * @param string $video_id + * @param string $url + * @param string $error + * @param string $state + * @return array + */ + private function build_error_result( $video_id, $url, $error, $state = 'error' ) { + return [ + 'success' => true, + 'data' => [ + 'type' => 'video', + 'video_id' => $video_id, + 'state' => $state, + 'error' => $error, + 'url' => $url, + ], + ]; + } +} diff --git a/src/Notion/Controllers/Notion_Preview_Controller.php b/src/Notion/Controllers/Notion_Preview_Controller.php new file mode 100644 index 000000000..b1a5e0dac --- /dev/null +++ b/src/Notion/Controllers/Notion_Preview_Controller.php @@ -0,0 +1,757 @@ +whereNull( 'project_id' ) + ->first(); + + if ( $setting && ! empty( $setting->value ) ) { + return $setting->value; + } + + return false; + } + + /** + * Check if Notion previews are enabled. + * + * @return bool + */ + private function is_previews_enabled() { + $setting = Settings::where( 'key', 'notion_enable_previews' ) + ->whereNull( 'project_id' ) + ->first(); + + if ( $setting ) { + return ! in_array( $setting->value, [ false, 'false', '0', 0 ], true ); + } + + return true; + } + + /** + * Build HTTP headers for Notion API requests. + * + * @param string|null $token Optional token override. + * @return array + */ + private function get_api_headers( $token = null ) { + if ( $token === null ) { + $token = $this->get_saved_token(); + } + + $headers = [ + 'Notion-Version' => self::NOTION_API_VERSION, + 'Content-Type' => 'application/json', + 'User-Agent' => 'WordPress-PM-Plugin', + ]; + + if ( $token && $token !== false ) { + $headers['Authorization'] = 'Bearer ' . $token; + } + + return $headers; + } + + /** + * Test Notion connection with the provided or saved token. + * + * @param WP_REST_Request $request + * @return array + */ + public function test_connection( WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + + if ( $token === '__saved__' ) { + $token = $this->get_saved_token(); + + if ( ! $token ) { + return [ + 'success' => false, + 'error' => __( 'No token saved. Please enter a Notion Internal Integration Token and save settings first.', 'wedevs-project-manager' ), + ]; + } + } + + if ( empty( $token ) ) { + return [ + 'success' => false, + 'error' => __( 'A Notion integration token is required. Please enter your token.', 'wedevs-project-manager' ), + ]; + } + + $headers = $this->get_api_headers( $token ); + + // Test with /users/me endpoint + $response = wp_remote_get( 'https://api.notion.com/v1/users/me', [ + 'timeout' => 15, + 'headers' => $headers, + 'sslverify' => true, + ] ); + + if ( is_wp_error( $response ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Notion API connection error: ' . $response->get_error_message() ); + } + + return [ + 'success' => false, + 'error' => __( 'Could not connect to Notion. Please check your network and try again.', 'wedevs-project-manager' ), + ]; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status_code === 401 ) { + return [ + 'success' => false, + 'error' => __( 'Invalid token. Please check your Notion Internal Integration Token.', 'wedevs-project-manager' ), + ]; + } + + if ( $status_code !== 200 || empty( $body ) ) { + return [ + 'success' => false, + 'error' => sprintf( + __( 'Notion API returned status %d.', 'wedevs-project-manager' ), + $status_code + ), + ]; + } + + $result = [ + 'success' => true, + 'data' => [ + 'bot_name' => isset( $body['name'] ) ? sanitize_text_field( $body['name'] ) : '', + 'bot_type' => isset( $body['type'] ) ? sanitize_text_field( $body['type'] ) : 'bot', + ], + ]; + + if ( isset( $body['bot']['workspace_name'] ) ) { + $result['data']['workspace'] = sanitize_text_field( $body['bot']['workspace_name'] ); + } + + return $result; + } + + /** + * Fetch preview data for a Notion page or database URL. + * + * @param WP_REST_Request $request + * @return array + */ + public function preview( WP_REST_Request $request ) { + if ( ! $this->is_previews_enabled() ) { + return [ + 'success' => false, + 'error' => __( 'Notion previews are disabled.', 'wedevs-project-manager' ), + ]; + } + + $token = $this->get_saved_token(); + + if ( ! $token ) { + return [ + 'success' => false, + 'error' => __( 'Notion integration token is not configured. Please add it in Settings → Notion.', 'wedevs-project-manager' ), + ]; + } + + $url = $request->get_param( 'url' ); + + if ( ! is_string( $url ) || empty( $url ) ) { + return [ + 'success' => false, + 'error' => __( 'URL is required.', 'wedevs-project-manager' ), + ]; + } + + $parsed = $this->parse_notion_url( $url ); + + if ( ! $parsed ) { + return [ + 'success' => false, + 'error' => __( 'Invalid Notion URL.', 'wedevs-project-manager' ), + ]; + } + + $force_refresh = filter_var( $request->get_param( 'force_refresh' ), FILTER_VALIDATE_BOOLEAN ); + + return $this->fetch_notion_data( $parsed, $force_refresh ); + } + + /** + * Fetch preview data for multiple Notion URLs at once. + * + * @param WP_REST_Request $request + * @return array + */ + public function batch_preview( WP_REST_Request $request ) { + if ( ! $this->is_previews_enabled() ) { + return [ + 'success' => false, + 'error' => __( 'Notion previews are disabled.', 'wedevs-project-manager' ), + ]; + } + + $token = $this->get_saved_token(); + + if ( ! $token ) { + return [ + 'success' => false, + 'error' => __( 'Notion integration token is not configured.', 'wedevs-project-manager' ), + ]; + } + + $urls = $request->get_param( 'urls' ); + + if ( empty( $urls ) || ! is_array( $urls ) ) { + return [ + 'success' => false, + 'error' => __( 'URLs array is required.', 'wedevs-project-manager' ), + ]; + } + + $urls = array_slice( $urls, 0, 10 ); + $results = []; + + foreach ( $urls as $url ) { + $parsed = $this->parse_notion_url( $url ); + + if ( ! $parsed ) { + $results[ $url ] = [ + 'success' => false, + 'error' => __( 'Invalid Notion URL.', 'wedevs-project-manager' ), + ]; + continue; + } + + $results[ $url ] = $this->fetch_notion_data( $parsed, false ); + } + + return [ + 'success' => true, + 'data' => $results, + ]; + } + + /** + * Parse a Notion URL into its components. + * + * Supported URL patterns: + * - https://www.notion.so/Page-Title-{32hex} + * - https://www.notion.so/{workspace}/Page-Title-{32hex} + * - https://www.notion.so/{workspace}/{32hex}?v={view_id} + * - https://notion.so/{32hex} + * - https://www.notion.so/{workspace}/{32hex} + * + * @param string $url + * @return array|false + */ + private function parse_notion_url( $url ) { + $url = esc_url_raw( trim( $url ) ); + + // Validate domain + if ( ! preg_match( '#^https?://(?:www\.)?notion\.so/#i', $url ) ) { + return false; + } + + // Extract the ID (32 hex chars, possibly with dashes for UUID format) + // Pattern 1: ID at end of path after title slug: Page-Title-{32hex} + // Pattern 2: ID as path segment: /{workspace}/{32hex} or /{32hex} + $id = null; + + // Try to extract 32 hex char ID (no dashes) from end of last path segment + if ( preg_match( '/([a-f0-9]{32})(?:\?|#|$)/i', $url, $matches ) ) { + $id = $matches[1]; + } + // Try UUID format with dashes + elseif ( preg_match( '/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\?|#|$)/i', $url, $matches ) ) { + $id = str_replace( '-', '', $matches[1] ); + } + + if ( ! $id ) { + return false; + } + + // Format as UUID for Notion API + $uuid = sprintf( + '%s-%s-%s-%s-%s', + substr( $id, 0, 8 ), + substr( $id, 8, 4 ), + substr( $id, 12, 4 ), + substr( $id, 16, 4 ), + substr( $id, 20, 12 ) + ); + + // Check if URL contains database view indicator + $is_database = (bool) preg_match( '/[?&]v=[a-f0-9]/i', $url ); + + return [ + 'id' => sanitize_text_field( $uuid ), + 'id_raw' => sanitize_text_field( $id ), + 'is_database' => $is_database, + 'url' => $url, + ]; + } + + /** + * Fetch data from the Notion API with transient caching. + * + * Tries page endpoint first; if 404, tries database endpoint. + * + * @param array $parsed Parsed URL components. + * @param bool $force_refresh Whether to bypass cache. + * @return array + */ + private function fetch_notion_data( $parsed, $force_refresh = false ) { + $cache_key = 'pm_notion_preview_' . md5( $parsed['url'] ); + + if ( $force_refresh ) { + delete_transient( $cache_key ); + } + + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $uuid = $parsed['id']; + $headers = $this->get_api_headers(); + + // Decide which endpoint to try first + if ( $parsed['is_database'] ) { + $result = $this->try_fetch_database( $uuid, $headers, $parsed ); + + if ( $result === null ) { + $result = $this->try_fetch_page( $uuid, $headers, $parsed ); + } + } else { + $result = $this->try_fetch_page( $uuid, $headers, $parsed ); + + if ( $result === null ) { + $result = $this->try_fetch_database( $uuid, $headers, $parsed ); + } + } + + if ( $result === null ) { + $result = [ + 'success' => true, + 'data' => [ + 'type' => 'page', + 'id' => $uuid, + 'state' => 'error', + 'error' => __( 'Could not fetch data from Notion. Make sure the page or database is shared with your integration (In Notion: click ••• → Connections → Add your integration).', 'wedevs-project-manager' ), + 'url' => $parsed['url'], + ], + ]; + + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + } + + return $result; + } + + /** + * Try to fetch as a Notion page. + * + * @param string $uuid + * @param array $headers + * @param array $parsed + * @return array|null Returns null if 404 (so caller can try database). + */ + private function try_fetch_page( $uuid, $headers, $parsed ) { + $cache_key = 'pm_notion_preview_' . md5( $parsed['url'] ); + + $api_url = sprintf( 'https://api.notion.com/v1/pages/%s', $uuid ); + $response = wp_remote_get( $api_url, [ + 'timeout' => 10, + 'headers' => $headers, + 'sslverify' => true, + ] ); + + if ( is_wp_error( $response ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Notion API error (page): ' . $response->get_error_message() ); + } + $result = $this->build_error_result( 'page', $uuid, $parsed['url'], __( 'Could not connect to Notion.', 'wedevs-project-manager' ) ); + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + return $result; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + $body = null; + } + + if ( $status_code === 404 ) { + return null; // Let caller try database endpoint + } + + if ( $status_code === 401 || $status_code === 403 ) { + $result = [ + 'success' => true, + 'data' => [ + 'type' => 'page', + 'id' => $uuid, + 'state' => 'access_denied', + 'error' => __( 'Page not shared with integration', 'wedevs-project-manager' ), + 'url' => $parsed['url'], + ], + ]; + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + return $result; + } + + if ( $status_code === 429 ) { + $result = $this->build_error_result( 'page', $uuid, $parsed['url'], __( 'Notion API rate limit exceeded. Please try again later.', 'wedevs-project-manager' ), 'rate_limited' ); + set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS ); + return $result; + } + + if ( $status_code !== 200 || empty( $body ) ) { + $result = $this->build_error_result( 'page', $uuid, $parsed['url'], __( 'Unable to fetch data from Notion.', 'wedevs-project-manager' ) ); + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + return $result; + } + + // Parse page data + $title = $this->extract_page_title( $body ); + $icon = $this->extract_icon( $body ); + $cover = $this->extract_cover( $body ); + + $last_edited_by = null; + $last_edited_time = isset( $body['last_edited_time'] ) ? sanitize_text_field( $body['last_edited_time'] ) : ''; + + if ( isset( $body['last_edited_by']['id'] ) ) { + $last_edited_by = $this->fetch_user_info( $body['last_edited_by']['id'], $headers ); + } + + $parent_type = 'workspace'; + if ( isset( $body['parent']['type'] ) ) { + $parent_type = sanitize_text_field( $body['parent']['type'] ); + } + + $result = [ + 'success' => true, + 'data' => [ + 'type' => 'page', + 'id' => $uuid, + 'title' => $title, + 'icon' => $icon, + 'cover' => $cover, + 'last_edited_by' => $last_edited_by, + 'last_edited_time' => $last_edited_time, + 'url' => isset( $body['url'] ) ? esc_url_raw( $body['url'] ) : $parsed['url'], + 'parent_type' => $parent_type, + ], + ]; + + set_transient( $cache_key, $result, HOUR_IN_SECONDS ); + + return $result; + } + + /** + * Try to fetch as a Notion database. + * + * @param string $uuid + * @param array $headers + * @param array $parsed + * @return array|null Returns null if 404. + */ + private function try_fetch_database( $uuid, $headers, $parsed ) { + $cache_key = 'pm_notion_preview_' . md5( $parsed['url'] ); + + $api_url = sprintf( 'https://api.notion.com/v1/databases/%s', $uuid ); + $response = wp_remote_get( $api_url, [ + 'timeout' => 10, + 'headers' => $headers, + 'sslverify' => true, + ] ); + + if ( is_wp_error( $response ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'Notion API error (database): ' . $response->get_error_message() ); + } + $result = $this->build_error_result( 'database', $uuid, $parsed['url'], __( 'Could not connect to Notion.', 'wedevs-project-manager' ) ); + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + return $result; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + $body = null; + } + + if ( $status_code === 404 ) { + return null; + } + + if ( $status_code === 401 || $status_code === 403 ) { + $result = [ + 'success' => true, + 'data' => [ + 'type' => 'database', + 'id' => $uuid, + 'state' => 'access_denied', + 'error' => __( 'Database not shared with integration', 'wedevs-project-manager' ), + 'url' => $parsed['url'], + ], + ]; + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + return $result; + } + + if ( $status_code === 429 ) { + $result = $this->build_error_result( 'database', $uuid, $parsed['url'], __( 'Notion API rate limit exceeded. Please try again later.', 'wedevs-project-manager' ), 'rate_limited' ); + set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS ); + return $result; + } + + if ( $status_code !== 200 || empty( $body ) ) { + $result = $this->build_error_result( 'database', $uuid, $parsed['url'], __( 'Unable to fetch data from Notion.', 'wedevs-project-manager' ) ); + set_transient( $cache_key, $result, 2 * MINUTE_IN_SECONDS ); + return $result; + } + + // Parse database data + $title = $this->extract_database_title( $body ); + $icon = $this->extract_icon( $body ); + $cover = $this->extract_cover( $body ); + + $last_edited_by = null; + $last_edited_time = isset( $body['last_edited_time'] ) ? sanitize_text_field( $body['last_edited_time'] ) : ''; + + if ( isset( $body['last_edited_by']['id'] ) ) { + $last_edited_by = $this->fetch_user_info( $body['last_edited_by']['id'], $headers ); + } + + $parent_type = 'workspace'; + if ( isset( $body['parent']['type'] ) ) { + $parent_type = sanitize_text_field( $body['parent']['type'] ); + } + + $result = [ + 'success' => true, + 'data' => [ + 'type' => 'database', + 'id' => $uuid, + 'title' => $title, + 'icon' => $icon, + 'cover' => $cover, + 'last_edited_by' => $last_edited_by, + 'last_edited_time' => $last_edited_time, + 'url' => isset( $body['url'] ) ? esc_url_raw( $body['url'] ) : $parsed['url'], + 'parent_type' => $parent_type, + ], + ]; + + set_transient( $cache_key, $result, HOUR_IN_SECONDS ); + + return $result; + } + + /** + * Extract page title from Notion page response. + * + * @param array $body + * @return string + */ + private function extract_page_title( $body ) { + if ( ! isset( $body['properties'] ) || ! is_array( $body['properties'] ) ) { + return __( 'Untitled', 'wedevs-project-manager' ); + } + + foreach ( $body['properties'] as $prop ) { + if ( isset( $prop['type'] ) && $prop['type'] === 'title' && isset( $prop['title'] ) && is_array( $prop['title'] ) ) { + $parts = []; + foreach ( $prop['title'] as $text_obj ) { + if ( isset( $text_obj['plain_text'] ) ) { + $parts[] = $text_obj['plain_text']; + } + } + $title = implode( '', $parts ); + if ( ! empty( $title ) ) { + return sanitize_text_field( $title ); + } + } + } + + return __( 'Untitled', 'wedevs-project-manager' ); + } + + /** + * Extract database title from Notion database response. + * + * @param array $body + * @return string + */ + private function extract_database_title( $body ) { + if ( isset( $body['title'] ) && is_array( $body['title'] ) ) { + $parts = []; + foreach ( $body['title'] as $text_obj ) { + if ( isset( $text_obj['plain_text'] ) ) { + $parts[] = $text_obj['plain_text']; + } + } + $title = implode( '', $parts ); + if ( ! empty( $title ) ) { + return sanitize_text_field( $title ); + } + } + + return __( 'Untitled Database', 'wedevs-project-manager' ); + } + + /** + * Extract icon from Notion response (emoji or external URL). + * + * @param array $body + * @return string|null + */ + private function extract_icon( $body ) { + if ( ! isset( $body['icon'] ) || ! is_array( $body['icon'] ) ) { + return null; + } + + $icon = $body['icon']; + + $type = isset( $icon['type'] ) ? $icon['type'] : ''; + + if ( $type === 'emoji' && isset( $icon['emoji'] ) ) { + return sanitize_text_field( $icon['emoji'] ); + } + + if ( $type === 'external' && isset( $icon['external']['url'] ) ) { + return esc_url_raw( $icon['external']['url'] ); + } + + if ( $type === 'file' && isset( $icon['file']['url'] ) ) { + return esc_url_raw( $icon['file']['url'] ); + } + + return null; + } + + /** + * Extract cover image URL from Notion response. + * + * @param array $body + * @return string|null + */ + private function extract_cover( $body ) { + if ( ! isset( $body['cover'] ) || ! is_array( $body['cover'] ) ) { + return null; + } + + $cover = $body['cover']; + + $type = isset( $cover['type'] ) ? $cover['type'] : ''; + + if ( $type === 'external' && isset( $cover['external']['url'] ) ) { + return esc_url_raw( $cover['external']['url'] ); + } + + if ( $type === 'file' && isset( $cover['file']['url'] ) ) { + return esc_url_raw( $cover['file']['url'] ); + } + + return null; + } + + /** + * Fetch user info from Notion API. + * + * @param string $user_id + * @param array $headers + * @return array|null + */ + private function fetch_user_info( $user_id, $headers ) { + $cache_key = 'pm_notion_user_' . md5( $user_id ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $api_url = sprintf( 'https://api.notion.com/v1/users/%s', sanitize_text_field( $user_id ) ); + $response = wp_remote_get( $api_url, [ + 'timeout' => 5, + 'headers' => $headers, + 'sslverify' => true, + ] ); + + if ( is_wp_error( $response ) ) { + return null; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status_code !== 200 || empty( $body ) ) { + return null; + } + + $user_info = [ + 'name' => isset( $body['name'] ) ? sanitize_text_field( $body['name'] ) : '', + 'avatar_url' => isset( $body['avatar_url'] ) ? esc_url_raw( $body['avatar_url'] ) : '', + ]; + + set_transient( $cache_key, $user_info, DAY_IN_SECONDS ); + + return $user_info; + } + + /** + * Build a standard error result array. + * + * @param string $type + * @param string $uuid + * @param string $url + * @param string $error + * @param string $state + * @return array + */ + private function build_error_result( $type, $uuid, $url, $error, $state = 'error' ) { + return [ + 'success' => true, + 'data' => [ + 'type' => $type, + 'id' => $uuid, + 'state' => $state, + 'error' => $error, + 'url' => $url, + ], + ]; + } +} diff --git a/src/Settings/Models/Settings.php b/src/Settings/Models/Settings.php index 693580ac1..b8c14063b 100644 --- a/src/Settings/Models/Settings.php +++ b/src/Settings/Models/Settings.php @@ -24,7 +24,9 @@ class Settings extends Eloquent { 'ai_api_key', 'ai_api_key_openai', 'ai_api_key_anthropic', - 'ai_api_key_google' + 'ai_api_key_google', + 'github_access_token', + 'notion_access_token' ]; public function setValueAttribute( $value ) { diff --git a/src/Settings/Transformers/Settings_Transformer.php b/src/Settings/Transformers/Settings_Transformer.php index 8cd392480..49590dc64 100644 --- a/src/Settings/Transformers/Settings_Transformer.php +++ b/src/Settings/Transformers/Settings_Transformer.php @@ -30,6 +30,12 @@ public function transform( Settings $item ) { } else { $value = ''; } + } else if ( $item->key === 'github_access_token' || $item->key === 'notion_access_token' ) { + if ( !empty( $value ) ) { + $value = $this->mask_api_key( $value ); + } else { + $value = ''; + } } else { // For other hidden settings, return boolean $value = !empty( $value ) ? true : false; diff --git a/views/assets/src/components/common/comments.vue b/views/assets/src/components/common/comments.vue index aec8d89d1..71e0544c5 100644 --- a/views/assets/src/components/common/comments.vue +++ b/views/assets/src/components/common/comments.vue @@ -45,7 +45,10 @@