diff --git a/all_in_one_seo_pack.php b/all_in_one_seo_pack.php index f53740680..9a2069c8e 100644 --- a/all_in_one_seo_pack.php +++ b/all_in_one_seo_pack.php @@ -5,7 +5,7 @@ * Description: SEO for WordPress. Features like XML Sitemaps, SEO for custom post types, SEO for blogs, business sites, ecommerce sites, and much more. More than 100 million downloads since 2007. * Author: All in One SEO Team * Author URI: https://aioseo.com/ - * Version: 4.7.3.1 + * Version: 4.7.4.1 * Text Domain: all-in-one-seo-pack * Domain Path: /languages * License: GPL-3.0+ diff --git a/app/AIOSEO.php b/app/AIOSEO.php index a68134ae2..e37447677 100644 --- a/app/AIOSEO.php +++ b/app/AIOSEO.php @@ -323,6 +323,7 @@ public function load() { $this->filters = $this->pro ? new Pro\Main\Filters() : new Lite\Main\Filters(); $this->crawlCleanup = new Common\QueryArgs\CrawlCleanup(); $this->emailReports = new Common\EmailReports\EmailReports(); + $this->writingAssistant = new Common\WritingAssistant\WritingAssistant(); if ( ! wp_doing_ajax() && ! wp_doing_cron() ) { $this->rss = new Common\Rss(); diff --git a/app/AIOSEOAbstract.php b/app/AIOSEOAbstract.php index f0727a983..e2e4da7ed 100644 --- a/app/AIOSEOAbstract.php +++ b/app/AIOSEOAbstract.php @@ -580,4 +580,13 @@ abstract class AIOSEOAbstract { * @var null|\AIOSEO\Plugin\Common\EmailReports\EmailReports */ public $emailReports = null; + + /** + * WritingAssistant class instance. + * + * @since 4.7.4 + * + * @var null|\AIOSEO\Plugin\Common\WritingAssistant\WritingAssistant + */ + public $writingAssistant = null; } \ No newline at end of file diff --git a/app/Common/Admin/Admin.php b/app/Common/Admin/Admin.php index 783c6f604..e8c20f6d0 100644 --- a/app/Common/Admin/Admin.php +++ b/app/Common/Admin/Admin.php @@ -88,6 +88,7 @@ class Admin { */ public function __construct() { new SeoAnalysis(); + new WritingAssistant(); include_once ABSPATH . 'wp-admin/includes/plugin.php'; if ( diff --git a/app/Common/Admin/PostSettings.php b/app/Common/Admin/PostSettings.php index 12b816c14..9b52d22e9 100644 --- a/app/Common/Admin/PostSettings.php +++ b/app/Common/Admin/PostSettings.php @@ -334,7 +334,7 @@ public function getPostTypeOverview( $postType ) { '{"focus":{"keyphrase":""%', '{"focus":{"keyphrase":""%', $postType, - ...$specialPageIds + ...array_values( $specialPageIds ) ), ARRAY_A ); diff --git a/app/Common/Admin/WritingAssistant.php b/app/Common/Admin/WritingAssistant.php new file mode 100644 index 000000000..c5cb3df06 --- /dev/null +++ b/app/Common/Admin/WritingAssistant.php @@ -0,0 +1,110 @@ +delete(); + } + + /** + * Adds a meta box to the page/posts screens. + * + * @since 4.7.4 + * + * @return void + */ + public function addMetabox() { + if ( ! aioseo()->access->hasCapability( 'aioseo_page_writing_assistant_settings' ) ) { + return; + } + + if ( + ! aioseo()->options->writingAssistant->postTypes->all && + ! in_array( get_post_type(), aioseo()->options->writingAssistant->postTypes->included, true ) + ) { + return; + } + + // Skip post types that do not support an editor. + if ( ! post_type_supports( get_post_type(), 'editor' ) ) { + return; + } + + add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAssets' ] ); + + // Translators: 1 - The plugin short name ("AIOSEO"). + $aioseoMetaboxTitle = sprintf( esc_html__( '%1$s Writing Assistant', 'all-in-one-seo-pack' ), AIOSEO_PLUGIN_SHORT_NAME ); + + add_meta_box( + 'aioseo-writing-assistant-metabox', + $aioseoMetaboxTitle, + [ $this, 'renderMetabox' ], + null, + 'normal', + 'low' + ); + } + + /** + * Render the on-page settings metabox with the Vue App wrapper. + * + * @since 4.7.4 + * + * @return void + */ + public function renderMetabox() { + ?> +
+ templates->getTemplate( 'parts/loader.php' ); ?> +
+ helpers->isScreenBase( 'post' ) ) { + return; + } + + aioseo()->core->assets->load( + 'src/vue/standalone/writing-assistant/main.js', + [], + aioseo()->writingAssistant->helpers->getStandaloneVueData(), + 'aioseoWritingAssistant' + ); + } +} \ No newline at end of file diff --git a/app/Common/Api/Api.php b/app/Common/Api/Api.php index 5304d6bbe..4f40ea91d 100644 --- a/app/Common/Api/Api.php +++ b/app/Common/Api/Api.php @@ -38,7 +38,11 @@ class Api { 'user/(?P[\d]+)/image' => [ 'callback' => [ 'User', 'getUserImage' ], 'access' => 'aioseo_page_social_settings' ], 'tags' => [ 'callback' => [ 'Tags', 'getTags' ], 'access' => 'everyone' ], 'search-statistics/url/auth' => [ 'callback' => [ 'SearchStatistics', 'getAuthUrl' ], 'access' => [ 'aioseo_search_statistics_settings', 'aioseo_general_settings', 'aioseo_setup_wizard' ] ], // phpcs:ignore Generic.Files.LineLength.MaxExceeded - 'search-statistics/url/reauth' => [ 'callback' => [ 'SearchStatistics', 'getReauthUrl' ], 'access' => [ 'aioseo_search_statistics_settings', 'aioseo_general_settings' ] ] + 'search-statistics/url/reauth' => [ 'callback' => [ 'SearchStatistics', 'getReauthUrl' ], 'access' => [ 'aioseo_search_statistics_settings', 'aioseo_general_settings' ] ], + 'writing-assistant/keyword/(?P[\d]+)' => [ 'callback' => [ 'WritingAssistant', 'getPostKeyword' ], 'access' => 'aioseo_page_writing_assistant_settings' ], + 'writing-assistant/user-info' => [ 'callback' => [ 'WritingAssistant', 'getUserInfo' ], 'access' => 'aioseo_page_writing_assistant_settings' ], + 'writing-assistant/user-options' => [ 'callback' => [ 'WritingAssistant', 'getUserOptions' ], 'access' => 'aioseo_page_writing_assistant_settings' ], + 'writing-assistant/report-history' => [ 'callback' => [ 'WritingAssistant', 'getReportHistory' ], 'access' => 'aioseo_page_writing_assistant_settings' ] ], 'POST' => [ 'htaccess' => [ 'callback' => [ 'Tools', 'saveHtaccess' ], 'access' => 'aioseo_tools_settings' ], @@ -130,24 +134,44 @@ class Api { ], 'crawl-cleanup' => [ 'callback' => [ 'CrawlCleanup', 'fetchLogs', 'AIOSEO\\Plugin\\Common\\QueryArgs' ], - 'access' => [ 'aioseo_search_appearance_settings' ] + 'access' => 'aioseo_search_appearance_settings' ], 'crawl-cleanup/block' => [ 'callback' => [ 'CrawlCleanup', 'blockArg', 'AIOSEO\\Plugin\\Common\\QueryArgs' ], - 'access' => [ 'aioseo_search_appearance_settings' ] + 'access' => 'aioseo_search_appearance_settings' ], 'crawl-cleanup/delete-blocked' => [ 'callback' => [ 'CrawlCleanup', 'deleteBlocked', 'AIOSEO\\Plugin\\Common\\QueryArgs' ], - 'access' => [ 'aioseo_search_appearance_settings' ] + 'access' => 'aioseo_search_appearance_settings' ], 'crawl-cleanup/delete-unblocked' => [ 'callback' => [ 'CrawlCleanup', 'deleteLog', 'AIOSEO\\Plugin\\Common\\QueryArgs' ], - 'access' => [ 'aioseo_search_appearance_settings' ] + 'access' => 'aioseo_search_appearance_settings' ], 'email-summary/send' => [ 'callback' => [ 'EmailSummary', 'send' ], 'access' => 'aioseo_page_advanced_settings' ], + 'writing-assistant/process' => [ + 'callback' => [ 'WritingAssistant', 'processKeyword' ], + 'access' => 'aioseo_page_writing_assistant_settings' + ], + 'writing-assistant/content-analysis' => [ + 'callback' => [ 'WritingAssistant', 'getContentAnalysis' ], + 'access' => 'aioseo_page_writing_assistant_settings' + ], + 'writing-assistant/disconnect' => [ + 'callback' => [ 'WritingAssistant', 'disconnect' ], + 'access' => 'aioseo_page_writing_assistant_settings' + ], + 'writing-assistant/user-options' => [ + 'callback' => [ 'WritingAssistant', 'saveUserOptions' ], + 'access' => 'aioseo_page_writing_assistant_settings' + ], + 'writing-assistant/set-report-progress' => [ + 'callback' => [ 'WritingAssistant', 'setReportProgress' ], + 'access' => 'aioseo_page_writing_assistant_settings' + ] ], 'DELETE' => [ 'backup' => [ 'callback' => [ 'Tools', 'deleteBackup' ], 'access' => 'aioseo_tools_settings' ], diff --git a/app/Common/Api/Settings.php b/app/Common/Api/Settings.php index 2e2178425..6490d81af 100644 --- a/app/Common/Api/Settings.php +++ b/app/Common/Api/Settings.php @@ -719,6 +719,9 @@ public static function doTask( $request ) { aioseo()->internalOptions->internal->deprecatedOptions = array_values( $enabledDeprecated ); aioseo()->internalOptions->save( true ); break; + case 'aioseo-reset-seoboost-logins': + aioseo()->writingAssistant->seoBoost->resetLogins(); + break; default: aioseo()->helpers->restoreCurrentBlog(); diff --git a/app/Common/Api/WritingAssistant.php b/app/Common/Api/WritingAssistant.php new file mode 100644 index 000000000..4f4ff51f9 --- /dev/null +++ b/app/Common/Api/WritingAssistant.php @@ -0,0 +1,308 @@ +get_json_params(); + $postId = absint( $body['postId'] ); + $keywordText = sanitize_text_field( $body['keyword'] ); + $country = sanitize_text_field( $body['country'] ); + $language = sanitize_text_field( strtolower( $body['language'] ) ); + + if ( empty( $keywordText ) || empty( $country ) || empty( $language ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => __( 'Missing data to generate a report', 'all-in-one-seo-pack' ) + ] ); + } + + $keyword = Models\WritingAssistantKeyword::getKeyword( $keywordText, $country, $language ); + $writingAssistantPost = Models\WritingAssistantPost::getPost( $postId ); + if ( $keyword->exists() ) { + $writingAssistantPost->attachKeyword( $keyword->id ); + + // Returning early will let the UI code start polling the keyword. + return new \WP_REST_Response( [ + 'success' => true, + 'progress' => $keyword->progress + ], 200 ); + } + + // Start a new keyword process. + $processResult = aioseo()->writingAssistant->seoBoost->service->processKeyword( $keywordText, $country, $language ); + if ( is_wp_error( $processResult ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $processResult->get_error_message() + ] ); + } + + // Store the new keyword. + $keyword->uuid = $processResult['slug']; + $keyword->progress = 0; + $keyword->save(); + + // Update the writing assistant post with the current keyword. + $writingAssistantPost->attachKeyword( $keyword->id ); + + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } + + /** + * Get current keyword for a Post. + * + * @since 4.7.4 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function getPostKeyword( $request ) { + $postId = $request->get_param( 'postId' ); + + if ( empty( $postId ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => __( 'Empty Post ID', 'all-in-one-seo-pack' ) + ], 404 ); + } + + $keyword = Models\WritingAssistantPost::getKeyword( $postId ); + if ( $keyword && 100 !== $keyword->progress ) { + // Update progress. + $newProgress = aioseo()->writingAssistant->seoBoost->service->getProgressAndResult( $keyword->uuid ); + if ( is_wp_error( $newProgress ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $newProgress->get_error_message() + ], 200 ); + } + + if ( 'success' !== $newProgress['status'] ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $newProgress['msg'] + ], 200 ); + } + + $keyword->progress = ! empty( $newProgress['report']['progress'] ) ? $newProgress['report']['progress'] : $keyword->progress; + + if ( ! empty( $newProgress['report']['keywords'] ) ) { + $keyword->keywords = $newProgress['report']['keywords']; + } + + if ( ! empty( $newProgress['report']['competitors'] ) ) { + $keyword->competitors = [ + 'competitors' => $newProgress['report']['competitors'], + 'summary' => $newProgress['report']['competitors_summary'] + ]; + } + + $keyword->save(); + } + + // Return a refreshed keyword here because we need some parsed data. + $keyword = Models\WritingAssistantPost::getKeyword( $postId ); + + return new \WP_REST_Response( $keyword, 200 ); + } + + /** + * Get the content analysis for a post. + * + * @since 4.7.4 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function getContentAnalysis( $request ) { + $title = $request->get_param( 'title' ); + $description = $request->get_param( 'description' ); + $content = apply_filters( 'the_content', $request->get_param( 'content' ) ); + $postId = $request->get_param( 'postId' ); + if ( empty( $content ) || empty( $postId ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => __( 'Empty Content or Post ID', 'all-in-one-seo-pack' ) + ], 200 ); + } + + $keyword = Models\WritingAssistantPost::getKeyword( $postId ); + if ( + ! $keyword || + ! $keyword->exists() || + 100 !== $keyword->progress + ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => __( 'Keyword not found or not ready', 'all-in-one-seo-pack' ) + ], 200 ); + } + + $writingAssistantPost = Models\WritingAssistantPost::getPost( $postId ); + + // Make sure we're not analysing the same content again. + $contentHash = sha1( $content ); + if ( + ! empty( $writingAssistantPost->content_analysis ) && + $writingAssistantPost->content_analysis_hash === $contentHash + ) { + return new \WP_REST_Response( $writingAssistantPost->content_analysis, 200 ); + } + + // Call SEOBoost service to get the content analysis. + $contentAnalysis = aioseo()->writingAssistant->seoBoost->service->getContentAnalysis( $title, $description, $content, $keyword->uuid ); + if ( is_wp_error( $contentAnalysis ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $contentAnalysis->get_error_message() + ], 200 ); + } + + // Update the post with the content analysis. + $writingAssistantPost->content_analysis = $contentAnalysis['result']; + $writingAssistantPost->content_analysis_hash = $contentHash; + $writingAssistantPost->save(); + + return new \WP_REST_Response( $contentAnalysis['result'], 200 ); + } + + /** + * Get the user info. + * + * @since 4.7.4 + * + * @return \WP_REST_Response The response. + */ + public static function getUserInfo() { + $userInfo = aioseo()->writingAssistant->seoBoost->service->getUserInfo(); + if ( is_wp_error( $userInfo ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $userInfo->get_error_message() + ], 200 ); + } + + if ( empty( $userInfo['status'] ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => __( 'Empty response from service', 'all-in-one-seo-pack' ) + ], 200 ); + } + + if ( 'success' !== $userInfo['status'] ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $userInfo['msg'] + ], 200 ); + } + + return new \WP_REST_Response( $userInfo, 200 ); + } + + /** + * Get the user info. + * + * @since 4.7.4 + * + * @return \WP_REST_Response The response. + */ + public static function getUserOptions() { + $userOptions = aioseo()->writingAssistant->seoBoost->getUserOptions(); + if ( empty( $userOptions ) ) { + aioseo()->writingAssistant->seoBoost->refreshUserOptions(); + } + + return new \WP_REST_Response( $userOptions, 200 ); + } + + /** + * Get the report history. + * + * @since 4.7.4 + * + * @return \WP_REST_Response The response. + */ + public static function getReportHistory() { + $reportHistory = aioseo()->writingAssistant->seoBoost->getReportHistory(); + + if ( is_wp_error( $reportHistory ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'error' => $reportHistory->get_error_message() + ], 200 ); + } + + return new \WP_REST_Response( $reportHistory, 200 ); + } + + /** + * Disconnect the user. + * + * @since 4.7.4 + * + * @return \WP_REST_Response The response. + */ + public static function disconnect() { + delete_user_meta( get_current_user_id(), 'seoboost_access_token' ); + + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } + + /** + * Save user options. + * + * @since 4.7.4 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function saveUserOptions( $request ) { + $body = $request->get_json_params(); + + $userOptions = [ + 'country' => $body['country'], + 'language' => $body['language'], + ]; + + aioseo()->writingAssistant->seoBoost->setUserOptions( $userOptions ); + + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } + + /** + * Set the report progress. + * + * @since 4.7.4 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function setReportProgress( $request ) { + $body = $request->get_json_params(); + $keyword = Models\WritingAssistantPost::getKeyword( (int) $body['postId'] ); + $keyword->progress = (int) $body['progress']; + $keyword->save(); + + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } +} \ No newline at end of file diff --git a/app/Common/Main/Updates.php b/app/Common/Main/Updates.php index 732912f8c..d96b01381 100644 --- a/app/Common/Main/Updates.php +++ b/app/Common/Main/Updates.php @@ -218,6 +218,11 @@ public function runUpdates() { $this->deprecateBreadcrumbsEnabledSetting(); } + if ( version_compare( $lastActiveVersion, '4.7.4', '<' ) ) { + $this->addWritingAssistantTables(); + aioseo()->access->addCapabilities(); + } + do_action( 'aioseo_run_updates', $lastActiveVersion ); // Always clear the cache if the last active version is different from our current. @@ -1697,4 +1702,64 @@ public function deprecateBreadcrumbsEnabledSetting() { aioseo()->options->deprecated->breadcrumbs->enable = false; } + + /** + * Add tables for Writing Assistant. + * + * @since 4.7.4 + * + * @return void + */ + private function addWritingAssistantTables() { + $db = aioseo()->core->db->db; + $charsetCollate = ''; + + if ( ! empty( $db->charset ) ) { + $charsetCollate .= "DEFAULT CHARACTER SET {$db->charset}"; + } + if ( ! empty( $db->collate ) ) { + $charsetCollate .= " COLLATE {$db->collate}"; + } + + if ( ! aioseo()->core->db->tableExists( 'aioseo_writing_assistant_posts' ) ) { + $tableName = $db->prefix . 'aioseo_writing_assistant_posts'; + + aioseo()->core->db->execute( + "CREATE TABLE {$tableName} ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned DEFAULT NULL, + `keyword_id` bigint(20) unsigned DEFAULT NULL, + `content_analysis_hash` VARCHAR(40) DEFAULT NULL, + `content_analysis` text DEFAULT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ndx_aioseo_writing_assistant_posts_post_id (post_id), + KEY ndx_aioseo_writing_assistant_posts_keyword_id (keyword_id) + ) {$charsetCollate};" + ); + } + + if ( ! aioseo()->core->db->tableExists( 'aioseo_writing_assistant_keywords' ) ) { + $tableName = $db->prefix . 'aioseo_writing_assistant_keywords'; + + aioseo()->core->db->execute( + "CREATE TABLE {$tableName} ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(40) NOT NULL, + `keyword` varchar(255) NOT NULL, + `country` varchar(10) NOT NULL DEFAULT 'us', + `language` varchar(10) NOT NULL DEFAULT 'en', + `progress` tinyint(3) DEFAULT 0, + `keywords` mediumtext NULL, + `competitors` mediumtext NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ndx_aioseo_writing_assistant_keywords_uuid (uuid), + KEY ndx_aioseo_writing_assistant_keywords_keyword (keyword) + ) {$charsetCollate};" + ); + } + } } \ No newline at end of file diff --git a/app/Common/Migration/GeneralSettings.php b/app/Common/Migration/GeneralSettings.php index 602bf7469..779b6ae05 100644 --- a/app/Common/Migration/GeneralSettings.php +++ b/app/Common/Migration/GeneralSettings.php @@ -475,7 +475,7 @@ private function migrateDescriptionFormat() { empty( $this->oldOptions['aiosp_skip_excerpt'] ) ) { foreach ( aioseo()->helpers->getPublicPostTypes() as $postType ) { - if ( empty( $postType['hasExcerpt'] ) ) { + if ( empty( $postType['supports']['excerpt'] ) ) { continue; } diff --git a/app/Common/Models/WritingAssistantKeyword.php b/app/Common/Models/WritingAssistantKeyword.php new file mode 100644 index 000000000..0562c8449 --- /dev/null +++ b/app/Common/Models/WritingAssistantKeyword.php @@ -0,0 +1,77 @@ +core->db->start( 'aioseo_writing_assistant_keywords' ) + ->where( 'keyword', $keyword ) + ->where( 'country', $country ) + ->where( 'language', $language ) + ->run() + ->model( 'AIOSEO\Plugin\Common\Models\WritingAssistantKeyword' ); + + if ( ! $dbKeyword->exists() ) { + $dbKeyword->keyword = $keyword; + $dbKeyword->country = $country; + $dbKeyword->language = $language; + } + + return $dbKeyword; + } +} \ No newline at end of file diff --git a/app/Common/Models/WritingAssistantPost.php b/app/Common/Models/WritingAssistantPost.php new file mode 100644 index 000000000..0b7d92716 --- /dev/null +++ b/app/Common/Models/WritingAssistantPost.php @@ -0,0 +1,158 @@ +content_analysis ) ? (array) $post->content_analysis : []; + } + + /** + * Gets a writing assistant post. + * + * @since 4.7.4 + * + * @param int $postId A post ID. + * @return WritingAssistantPost The post object. + */ + public static function getPost( $postId ) { + $post = aioseo()->core->db->start( 'aioseo_writing_assistant_posts' ) + ->where( 'post_id', $postId ) + ->run() + ->model( 'AIOSEO\Plugin\Common\Models\WritingAssistantPost' ); + + if ( ! $post->exists() ) { + $post->post_id = $postId; + } + + return $post; + } + + /** + * Gets a post's current keyword. + * + * @since 4.7.4 + * + * @param int $postId A post ID. + * @return WritingAssistantKeyword|bool An attached keyword. + */ + public static function getKeyword( $postId ) { + $post = self::getPost( $postId ); + if ( ! $post->exists() || empty( $post->keyword_id ) ) { + return false; + } + + $keyword = aioseo()->core->db->start( 'aioseo_writing_assistant_keywords' ) + ->where( 'id', $post->keyword_id ) + ->run() + ->model( 'AIOSEO\Plugin\Common\Models\WritingAssistantKeyword' ); + + // This is here so this property is reactive in the frontend. + if ( ! empty( $keyword->keywords ) ) { + foreach ( $keyword->keywords as &$keyph ) { + $keyph->contentCount = 0; + } + } + + // Help sorting in the frontend. + if ( ! empty( $keyword->competitors->competitors ) ) { + foreach ( $keyword->competitors->competitors as &$competitor ) { + $competitor->wasAnalyzed = true; + if ( 0 >= $competitor->wordCount ) { + $competitor->wordCount = 0; + $competitor->readabilityScore = 999; + $competitor->readabilityGrade = ''; + $competitor->gradeScore = 0; + $competitor->grade = ''; + $competitor->wasAnalyzed = false; + } + + $competitor->readabilityScore = (float) $competitor->readabilityScore; + } + } + + return $keyword; + } + + /** + * Return if a post has a keyword. + * + * @since 4.7.4 + * + * @param int $postId A post ID. + * @return boolean Has a keyword. + */ + public static function hasKeyword( $postId ) { + $post = self::getPost( $postId ); + + return (bool) $post->keyword_id; + } + + /** + * Attaches a keyword to a post. + * + * @since 4.7.4 + * + * @param int $keywordId The keyword ID. + * @return void + */ + public function attachKeyword( $keywordId ) { + $this->keyword_id = $keywordId; + $this->save(); + } +} \ No newline at end of file diff --git a/app/Common/Options/DynamicOptions.php b/app/Common/Options/DynamicOptions.php index a29c65b84..a7439260d 100644 --- a/app/Common/Options/DynamicOptions.php +++ b/app/Common/Options/DynamicOptions.php @@ -177,7 +177,7 @@ protected function addDynamicPostTypeDefaults() { } $defaultTitle = '#post_title #separator_sa #site_title'; - $defaultDescription = $postType['hasExcerpt'] ? '#post_excerpt' : '#post_content'; + $defaultDescription = ! empty( $postType['supports']['excerpt'] ) ? '#post_excerpt' : '#post_content'; $defaultSchemaType = 'WebPage'; $defaultWebPageType = 'WebPage'; $defaultArticleType = 'BlogPosting'; diff --git a/app/Common/Options/Options.php b/app/Common/Options/Options.php index fcd0760d6..5fa1faa5a 100644 --- a/app/Common/Options/Options.php +++ b/app/Common/Options/Options.php @@ -454,6 +454,12 @@ class Options { ] ] ] + ], + 'writingAssistant' => [ + 'postTypes' => [ + 'all' => [ 'type' => 'boolean', 'default' => true ], + 'included' => [ 'type' => 'array', 'default' => [ 'post', 'page' ] ], + ] ] // phpcs:enable WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound ]; diff --git a/app/Common/Traits/Helpers/Arrays.php b/app/Common/Traits/Helpers/Arrays.php index 85e808670..3f655414c 100644 --- a/app/Common/Traits/Helpers/Arrays.php +++ b/app/Common/Traits/Helpers/Arrays.php @@ -252,7 +252,7 @@ public function createMultidimensionalArray( $keys, $value, $array = [] ) { /** * Sorts an array of arrays by a specific key. * - * @since {next} + * @since 4.7.4 * * @param array $arr The input array. * @param string $key The key to sort by. diff --git a/app/Common/Traits/Helpers/Vue.php b/app/Common/Traits/Helpers/Vue.php index be33bd709..7c1de1eb9 100644 --- a/app/Common/Traits/Helpers/Vue.php +++ b/app/Common/Traits/Helpers/Vue.php @@ -76,6 +76,7 @@ public function getVueData( $page = null, $staticPostId = null, $integration = n $this->setSeoRevisionsData(); $this->setToolsOrSettingsData(); $this->setPageBuilderData(); + $this->setWritingAssistantData(); $this->cache[ $hash ] = $this->data; @@ -629,6 +630,25 @@ public function getJedLocaleData( $domain ) { return $locale; } + /** + * Set Vue writing assistant data. + * + * @since 4.7.4 + * + * @return void + */ + private function setWritingAssistantData() { + // Settings page or not a post screen. + if ( + 'settings' !== $this->args['page'] && + ! aioseo()->helpers->isScreenBase( 'post' ) + ) { + return; + } + + $this->data['writingAssistantSettings'] = aioseo()->writingAssistant->helpers->getSettingsVueData(); + } + /** * Whether the notifications drawer should be shown or not. * diff --git a/app/Common/Traits/Helpers/Wp.php b/app/Common/Traits/Helpers/Wp.php index 6db3ee069..44357c01c 100644 --- a/app/Common/Traits/Helpers/Wp.php +++ b/app/Common/Traits/Helpers/Wp.php @@ -235,11 +235,11 @@ public function getPostType( $postTypeObject, $namesOnly = false, $hasArchivesOn 'label' => ucwords( $postTypeObject->label ), 'singular' => ucwords( $postTypeObject->labels->singular_name ), 'icon' => $postTypeObject->menu_icon, - 'hasExcerpt' => post_type_supports( $postTypeObject->name, 'excerpt' ), 'hasArchive' => $postTypeObject->has_archive, 'hierarchical' => $postTypeObject->hierarchical, 'taxonomies' => get_object_taxonomies( $name ), - 'slug' => isset( $postTypeObject->rewrite['slug'] ) ? $postTypeObject->rewrite['slug'] : $name + 'slug' => isset( $postTypeObject->rewrite['slug'] ) ? $postTypeObject->rewrite['slug'] : $name, + 'supports' => get_all_post_type_supports( $name ) ]; } diff --git a/app/Common/Utils/Access.php b/app/Common/Utils/Access.php index 7c288236b..c4182b6b0 100644 --- a/app/Common/Utils/Access.php +++ b/app/Common/Utils/Access.php @@ -37,6 +37,7 @@ class Access { 'aioseo_page_redirects_settings', 'aioseo_local_seo_settings', 'aioseo_page_local_seo_settings', + 'aioseo_page_writing_assistant_settings', 'aioseo_about_us_page', 'aioseo_setup_wizard', 'aioseo_page_seo_revisions_settings' diff --git a/app/Common/Utils/Tags.php b/app/Common/Utils/Tags.php index be0f8a0ab..ef75c35c1 100644 --- a/app/Common/Utils/Tags.php +++ b/app/Common/Utils/Tags.php @@ -639,7 +639,7 @@ public function getContext() { $context[ $postType['name'] . 'Description' ] = $context['postDescription']; // Check if the post type has an excerpt. - if ( empty( $postType['hasExcerpt'] ) ) { + if ( empty( $postType['supports']['excerpt'] ) ) { $phpTitleKey = array_search( 'post_excerpt', $context[ $postType['name'] . 'Title' ], true ); if ( false !== $phpTitleKey ) { unset( $context[ $postType['name'] . 'Title' ][ $phpTitleKey ] ); diff --git a/app/Common/Utils/VueSettings.php b/app/Common/Utils/VueSettings.php index 5d052a079..04a9bb54f 100644 --- a/app/Common/Utils/VueSettings.php +++ b/app/Common/Utils/VueSettings.php @@ -118,6 +118,8 @@ class VueSettings { '404Settings' => true, 'userProfiles' => true, 'queryArgLogs' => true, + 'writingAssistantSettings' => true, + 'writingAssistantCta' => true ], 'toggledRadio' => [ 'breadcrumbsShowMoreSeparators' => false, diff --git a/app/Common/WritingAssistant/SeoBoost/SeoBoost.php b/app/Common/WritingAssistant/SeoBoost/SeoBoost.php new file mode 100644 index 000000000..dff583b11 --- /dev/null +++ b/app/Common/WritingAssistant/SeoBoost/SeoBoost.php @@ -0,0 +1,261 @@ +service = new Service(); + + $returnParam = isset( $_GET['aioseo-writing-assistant'] ) ? sanitize_text_field( wp_unslash( $_GET['aioseo-writing-assistant'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( 'auth_return' === $returnParam ) { + add_action( 'init', [ $this, 'checkToken' ], 50 ); + } + + if ( 'ms_logged_in' === $returnParam ) { + add_action( 'init', [ $this, 'marketingSiteReturn' ], 50 ); + } + } + + /** + * Returns if the user has an access key. + * + * @since 4.7.4 + * + * @return bool + */ + public function isLoggedIn() { + return $this->getAccessToken() !== ''; + } + + /** + * Gets the login URL. + * + * @since 4.7.4 + * + * @return string The login URL. + */ + public function getLoginUrl() { + $url = $this->loginUrl; + if ( defined( 'AIOSEO_WRITING_ASSISTANT_LOGIN_URL' ) ) { + $url = AIOSEO_WRITING_ASSISTANT_LOGIN_URL; + } + + $params = [ + 'oauth' => true, + 'redirect' => get_site_url() . '?' . build_query( [ 'aioseo-writing-assistant' => 'auth_return' ] ) + ]; + + return trailingslashit( $url ) . '?' . build_query( $params ); + } + + /** + * Gets the login URL. + * + * @since 4.7.4 + * + * @return string The login URL. + */ + public function getCreateAccountUrl() { + $url = $this->createAccountUrl; + if ( defined( 'AIOSEO_WRITING_ASSISTANT_CREATE_ACCOUNT_URL' ) ) { + $url = AIOSEO_WRITING_ASSISTANT_CREATE_ACCOUNT_URL; + } + + $params = [ + 'url' => base64_encode( get_site_url() . '?' . build_query( [ 'aioseo-writing-assistant' => 'ms_logged_in' ] ) ), + 'writing-assistant-checkout' => true + ]; + + return trailingslashit( $url ) . '?' . build_query( $params ); + } + + /** + * Gets the user's access token. + * + * @since 4.7.4 + * + * @return string The access token. + */ + public function getAccessToken() { + return get_user_meta( get_current_user_id(), 'seoboost_access_token', true ); + } + + /** + * Sets the user's access token. + * + * @since 4.7.4 + * + * @return void + */ + public function setAccessToken( $accessToken ) { + update_user_meta( get_current_user_id(), 'seoboost_access_token', $accessToken ); + $this->refreshUserOptions(); + } + + /** + * Refreshes user options from SeoBoost. + * + * @since 4.7.4 + * + * @return void|\WP_Error + */ + public function refreshUserOptions() { + $userOptions = $this->service->getUserOptions(); + if ( is_wp_error( $userOptions ) ) { + return $userOptions; + } + + $this->setUserOptions( $userOptions ); + } + + /** + * Gets the user options. + * + * @since 4.7.4 + * + * @return array The user options. + */ + public function getUserOptions() { + return json_decode( get_user_meta( get_current_user_id(), 'seoboost_user_options', true ), true ); + } + + /** + * Gets the user options. + * + * @since 4.7.4 + * + * @param array $options The user options. + * @return array The user options. + */ + public function setUserOptions( $options ) { + if ( ! is_array( $options ) ) { + return []; + } + + $userOptions = $this->getUserOptions() ?? []; + $userOptions = array_merge( $userOptions, $options ); + + update_user_meta( get_current_user_id(), 'seoboost_user_options', wp_json_encode( $userOptions ) ); + + return $userOptions; + } + + /** + * Gets the user info from SEOBoost. + * + * @since 4.7.4 + * + * @return array|\WP_Error The user info or a WP_Error. + */ + public function getUserInfo() { + return $this->service->getUserInfo(); + } + + /** + * Checks the token. + * + * @since 4.7.4 + * + * @return void + */ + public function checkToken() { + $authToken = isset( $_GET['token'] ) ? sanitize_key( wp_unslash( $_GET['token'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( $authToken ) { + $accessToken = $this->service->getAccessToken( $authToken ); + + if ( ! is_wp_error( $accessToken ) && ! empty( $accessToken['token'] ) ) { + $this->setAccessToken( $accessToken['token'] ); + ?> + + + + + + core->db->delete( 'usermeta' )->where( 'meta_key', 'seoboost_access_token' )->run(); + aioseo()->core->db->delete( 'usermeta' )->where( 'meta_key', 'seoboost_user_options' )->run(); + } + + /** + * Gets the report history. + * + * @since 4.7.4 + * + * @return array|\WP_Error The report history. + */ + public function getReportHistory() { + return $this->service->getReportHistory(); + } +} \ No newline at end of file diff --git a/app/Common/WritingAssistant/SeoBoost/Service.php b/app/Common/WritingAssistant/SeoBoost/Service.php new file mode 100644 index 000000000..1618ee5d9 --- /dev/null +++ b/app/Common/WritingAssistant/SeoBoost/Service.php @@ -0,0 +1,236 @@ +doRequest( 'waAddNewReport', [ + 'params' => [ + 'keyword' => $keyword, + 'country' => $country, + 'language' => $language + ] + ] ); + + if ( is_wp_error( $reportRequest ) ) { + return $reportRequest; + } + + if ( empty( $reportRequest ) ) { + return new \WP_Error( 'service-error', __( 'Empty response from service', 'all-in-one-seo-pack' ) ); + } + + if ( 'success' !== $reportRequest['status'] ) { + return new \WP_Error( 'service-error', $reportRequest['msg'] ); + } + + return $reportRequest; + } + + /** + * Sends a post content to be analyzed. + * + * @since 4.7.4 + * + * @param string $title The title. + * @param string $description The description. + * @param string $content The content. + * @param string $reportSlug The report slug. + * @return array|\WP_Error The response. + */ + public function getContentAnalysis( $title, $description, $content, $reportSlug ) { + return $this->doRequest( 'waAnalyzeContent', [ + 'title' => $title, + 'description' => $description, + 'content' => $content, + 'slug' => $reportSlug + ] ); + } + + /** + * Gets the progress for a keyword. + * + * @since 4.7.4 + * + * @param string $uuid The uuid. + * @return array|\WP_Error The progress. + */ + public function getProgressAndResult( $uuid ) { + $response = $this->doRequest( 'waGetReport', [ 'slug' => $uuid ] ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( empty( $response ) ) { + return new \WP_Error( 'empty-progress-and-result', __( 'Empty progress and result.', 'all-in-one-seo-pack' ) ); + } + + return $response; + } + + /** + * Gets the user options. + * + * @since 4.7.4 + * + * @return array|\WP_Error The user options. + */ + public function getUserOptions() { + return $this->doRequest( 'waGetUserOptions' ); + } + + /** + * Gets the user information. + * + * @since 4.7.4 + * + * @return array|\WP_Error The user information. + */ + public function getUserInfo() { + return $this->doRequest( 'waGetUserInfo' ); + } + + /** + * Gets the access token. + * + * @since 4.7.4 + * + * @param string $authToken The auth token. + * @return array|\WP_Error The response. + */ + public function getAccessToken( $authToken ) { + return $this->doRequest( 'oauthaccess', [ 'token' => $authToken ] ); + } + + /** + * Refreshes the access token. + * + * @since 4.7.4 + * + * @return bool Was the token refreshed? + */ + private function refreshAccessToken() { + $newAccessToken = $this->doRequest( 'waRefreshAccessToken' ); + if ( + is_wp_error( $newAccessToken ) || + 'success' !== $newAccessToken['status'] + ) { + aioseo()->writingAssistant->seoBoost->setAccessToken( '' ); + + return false; + } + + aioseo()->writingAssistant->seoBoost->setAccessToken( $newAccessToken['token'] ); + + return true; + } + + /** + * Sends a POST request to the microservice. + * + * @since 4.7.4 + * + * @param string $path The path. + * @param array $requestBody The request body. + * @return array|\WP_Error Returns the response body or WP_Error if the request failed. + */ + private function doRequest( $path, $requestBody = [] ) { + $requestData = [ + 'headers' => [ + 'X-SeoBoost-Access-Token' => aioseo()->writingAssistant->seoBoost->getAccessToken(), + 'Content-Type' => 'application/json' + ], + 'timeout' => 60, + 'method' => 'GET' + ]; + + if ( ! empty( $requestBody ) ) { + $requestData['method'] = 'POST'; + $requestData['body'] = wp_json_encode( $requestBody ); + } + + $path = trailingslashit( $this->getUrl() ) . trailingslashit( $path ); + $response = wp_remote_request( $path, $requestData ); + $responseBody = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! $responseBody ) { + $response = new \WP_Error( 'service-failed', __( 'Error in the SeoBoost service. Please contact support.', 'all-in-one-seo-pack' ) ); + } + + if ( is_wp_error( $response ) ) { + return $response; + } + + // Refresh access token if expired and redo the request. + if ( + isset( $responseBody['error'] ) && + 'invalid-access-token' === $responseBody['error'] + ) { + if ( $this->refreshAccessToken() ) { + return $this->doRequest( $path, $requestBody ); + } + } + + return $responseBody; + } + + /** + * Returns the URL for the Writing Assistant service. + * + * @since 4.7.4 + * + * @return string The URL. + */ + public function getUrl() { + $url = $this->baseUrl; + if ( defined( 'AIOSEO_WRITING_ASSISTANT_SERVICE_URL' ) ) { + $url = AIOSEO_WRITING_ASSISTANT_SERVICE_URL; + } + + return $url; + } + + /** + * Gets the report history. + * + * @since 4.7.4 + * + * @return array|\WP_Error + */ + public function getReportHistory() { + return $this->doRequest( 'waGetReportHistory' ); + } +} \ No newline at end of file diff --git a/app/Common/WritingAssistant/Utils/Helpers.php b/app/Common/WritingAssistant/Utils/Helpers.php new file mode 100644 index 000000000..5d4f13c3d --- /dev/null +++ b/app/Common/WritingAssistant/Utils/Helpers.php @@ -0,0 +1,58 @@ + get_the_ID(), + 'report' => $keyword, + 'keywordText' => ! empty( $keyword->keyword ) ? $keyword->keyword : '', + 'contentAnalysis' => Models\WritingAssistantPost::getContentAnalysis( get_the_ID() ), + 'seoBoost' => [ + 'isLoggedIn' => aioseo()->writingAssistant->seoBoost->isLoggedIn(), + 'loginUrl' => aioseo()->writingAssistant->seoBoost->getLoginUrl(), + 'createAccountUrl' => aioseo()->writingAssistant->seoBoost->getCreateAccountUrl(), + 'userOptions' => aioseo()->writingAssistant->seoBoost->getUserOptions() + ] + ]; + } + + /** + * Gets the data for vue. + * + * @since 4.7.4 + * + * @return array An array of data. + */ + public function getSettingsVueData() { + return [ + 'seoBoost' => [ + 'isLoggedIn' => aioseo()->writingAssistant->seoBoost->isLoggedIn(), + 'loginUrl' => aioseo()->writingAssistant->seoBoost->getLoginUrl(), + 'createAccountUrl' => aioseo()->writingAssistant->seoBoost->getCreateAccountUrl(), + 'userOptions' => aioseo()->writingAssistant->seoBoost->getUserOptions() + ] + ]; + } +} \ No newline at end of file diff --git a/app/Common/WritingAssistant/WritingAssistant.php b/app/Common/WritingAssistant/WritingAssistant.php new file mode 100644 index 000000000..4c3a518d3 --- /dev/null +++ b/app/Common/WritingAssistant/WritingAssistant.php @@ -0,0 +1,44 @@ +helpers = new Utils\Helpers(); + $this->seoBoost = new SeoBoost\SeoBoost(); + } +} \ No newline at end of file diff --git a/readme.txt b/readme.txt index bfb7cceaa..91c1b5f87 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: SEO, Google Search Console, XML Sitemap, meta description, schema Tested up to: 6.6.2 Requires at least: 5.3 Requires PHP: 7.0 -Stable tag: 4.7.3.1 +Stable tag: 4.7.4.1 License: GPLv3 or later License URI: https://www.gnu.org/licenses/gpl-3.0.txt @@ -226,6 +226,12 @@ AIOSEO® is a registered trademark of Semper Plugins LLC. When writing about == Changelog == +**New in Version 4.7.4.1** + +* New: Writing Assistant + SEOBoost: Elevate Your Content Strategy! Integrate seamlessly with SEOBoost via AIOSEO to supercharge your WordPress content. Harness the power of AI to optimize your content, drive organic traffic, and outrank competitors. +* Fixed: Post Overview widget sometimes not loading on newer PHP versions. +* Fixed: Post Excerpt tag sometimes not listed in list of available smart tags. + **New in Version 4.7.3.1** * Fixed: Link format causing block errors in WP 6.4.5 and below. @@ -373,6 +379,6 @@ Additionally, AIOSEO can also provide you with data on the most frequently used == Upgrade Notice == -= 4.7.3.1 = += 4.7.4.1 = This update adds major improvements and bug fixes. \ No newline at end of file diff --git a/src/vue/assets/images/writing-assistant/connected.svg b/src/vue/assets/images/writing-assistant/connected.svg new file mode 100644 index 000000000..f0f5cae09 --- /dev/null +++ b/src/vue/assets/images/writing-assistant/connected.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/vue/assets/images/writing-assistant/icon-logo.svg b/src/vue/assets/images/writing-assistant/icon-logo.svg new file mode 100644 index 000000000..feccd3c75 --- /dev/null +++ b/src/vue/assets/images/writing-assistant/icon-logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/vue/assets/images/writing-assistant/login.svg b/src/vue/assets/images/writing-assistant/login.svg new file mode 100644 index 000000000..c577b28bf --- /dev/null +++ b/src/vue/assets/images/writing-assistant/login.svg @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/vue/assets/scss/app/variables.scss b/src/vue/assets/scss/app/variables.scss index 885135924..4b088200a 100644 --- a/src/vue/assets/scss/app/variables.scss +++ b/src/vue/assets/scss/app/variables.scss @@ -22,6 +22,7 @@ $red2: #AB2039; $red3: #F83C5D; $yellow: #FCFAE8; +$yellow-highlight: #e5d895; $white: #FFFFFF; $white2: #F0F2F5; diff --git a/src/vue/components/common/base/CollapseBox.vue b/src/vue/components/common/base/CollapseBox.vue new file mode 100644 index 000000000..a8736dbb3 --- /dev/null +++ b/src/vue/components/common/base/CollapseBox.vue @@ -0,0 +1,79 @@ + + + + + \ No newline at end of file diff --git a/src/vue/components/common/base/Select.vue b/src/vue/components/common/base/Select.vue index 89561f232..cf04c22d3 100644 --- a/src/vue/components/common/base/Select.vue +++ b/src/vue/components/common/base/Select.vue @@ -19,7 +19,7 @@ :filterable="filterable" :internal-search="true" :loading="isLoading" - :searchable="true" + :searchable="searchable" :open-direction="openDirection" :group-values="groupValues" :group-label="groupLabel" @@ -116,9 +116,15 @@ export default { return 'value' } }, - multiple : Boolean, - taggable : Boolean, - filterable : Boolean, + multiple : Boolean, + taggable : Boolean, + filterable : Boolean, + searchable : { + type : Boolean, + default () { + return true + } + }, placeholder : { type : String, default () { @@ -389,11 +395,10 @@ export default { &.small { height: 30px; - min-height: 30px; .multiselect__tags { - min-height: 30px; - padding: 8px 38px 8px 8px; + height: 30px; + padding: 0 34px 0 8px; .multiselect__placeholder { font-size: 14px; @@ -401,8 +406,8 @@ export default { } .multiselect__select { - height: 28px; - min-height: 28px; + height: 30px; + padding: 0 0 0 8px; } .multiselect__input { diff --git a/src/vue/components/common/core/LoadingBar.vue b/src/vue/components/common/core/LoadingBar.vue new file mode 100644 index 000000000..f4e161721 --- /dev/null +++ b/src/vue/components/common/core/LoadingBar.vue @@ -0,0 +1,72 @@ + + + + + \ No newline at end of file diff --git a/src/vue/components/common/core/PostTypeOptions.vue b/src/vue/components/common/core/PostTypeOptions.vue index afd23af79..f2efaf002 100644 --- a/src/vue/components/common/core/PostTypeOptions.vue +++ b/src/vue/components/common/core/PostTypeOptions.vue @@ -106,6 +106,12 @@ export default { default () { return [] } + }, + supports : { + type : Array, + default () { + return [] + } } }, data () { @@ -126,7 +132,12 @@ export default { }, postTypes () { return this.getRegisteredPostTypes[this.type].filter(postType => { - return !this.excluded.includes(postType.name) + let filtered = true + if (this.supports.length && postType.supports.length) { + filtered = this.supports.every(support => postType.supports.includes(support)) + } + + return filtered && !this.excluded.includes(postType.name) }) } }, diff --git a/src/vue/components/common/core/SimpleTable.vue b/src/vue/components/common/core/SimpleTable.vue new file mode 100644 index 000000000..581a4b8b3 --- /dev/null +++ b/src/vue/components/common/core/SimpleTable.vue @@ -0,0 +1,463 @@ + + + + + \ No newline at end of file diff --git a/src/vue/components/common/core/main/Tabs.vue b/src/vue/components/common/core/main/Tabs.vue index 48ea1e28c..153948967 100644 --- a/src/vue/components/common/core/main/Tabs.vue +++ b/src/vue/components/common/core/main/Tabs.vue @@ -24,25 +24,7 @@ {{ tab.name }} - - - - - - - + + + + + {{ strings.csv }} + + +
- +
{ + this.pageNumber = 1 this.editRow(-1) this.$emit('search', this.searchTerm) }, 100) @@ -601,6 +636,30 @@ export default { if (checked) { checked.forEach(c => (c.checked = false)) } + }, + exportCsv () { + // Determine which columns to export. + const colsToExport = this.exportColumns || this.columns + + // Map data to export same as exportColumns. + let exportData = this.exportData.length ? this.exportData : this.rows + exportData = exportData.map((row) => { + const newRow = [] + colsToExport.forEach((col) => { + newRow[col.slug] = col?.value ? col.value(row) : row[col.slug] + }) + + return newRow + }) + + // Extract headers. + const header = colsToExport.map(col => col.label) + + // Add headers and data. + exportData = [ header ].concat(exportData) + + // Download. + downloadFile(arrayToCsv(exportData), this.getExportFileName) } }, created () { @@ -980,6 +1039,16 @@ export default { } } } + + .export { + margin-left: auto; + + svg { + width: 14px; + height: 14px; + margin-right: 5px; + } + } } #aioseo-settings { diff --git a/src/vue/components/common/core/wp/TableHeaderFooter.vue b/src/vue/components/common/core/wp/TableHeaderFooter.vue index b0d033e10..1e7e6493f 100644 --- a/src/vue/components/common/core/wp/TableHeaderFooter.vue +++ b/src/vue/components/common/core/wp/TableHeaderFooter.vue @@ -77,5 +77,29 @@ export default { margin: 0; } } + + &.sorted { + .sorting-indicator { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid; + display: inline-block; + height: 0; + margin-left: 5px; + vertical-align: middle; + width: 0; + + &:before, &:after{ + display: none; + } + } + + &.asc { + .sorting-indicator { + border-top: none; + border-bottom: 4px solid; + } + } + } } \ No newline at end of file diff --git a/src/vue/components/common/svg/BarChart.vue b/src/vue/components/common/svg/BarChart.vue new file mode 100644 index 000000000..710034010 --- /dev/null +++ b/src/vue/components/common/svg/BarChart.vue @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/src/vue/components/common/svg/circle/Empty.vue b/src/vue/components/common/svg/circle/Empty.vue new file mode 100644 index 000000000..9c74ec632 --- /dev/null +++ b/src/vue/components/common/svg/circle/Empty.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/vue/components/common/svg/right-arrow/Simple.vue b/src/vue/components/common/svg/right-arrow/Simple.vue new file mode 100644 index 000000000..bcba83123 --- /dev/null +++ b/src/vue/components/common/svg/right-arrow/Simple.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/vue/pages/link-assistant/views/Main.vue b/src/vue/pages/link-assistant/views/Main.vue index 7fe1d03e3..c98e6bc6d 100644 --- a/src/vue/pages/link-assistant/views/Main.vue +++ b/src/vue/pages/link-assistant/views/Main.vue @@ -196,7 +196,7 @@ onMounted(() => { } } - #the-list .aioseo-wp-table.link-assistant-inner-table { + .aioseo-wp-table.link-assistant-inner-table { .wp-table table { border: 0; diff --git a/src/vue/pages/settings/router/paths.js b/src/vue/pages/settings/router/paths.js index b5252f3f5..76f996ad3 100644 --- a/src/vue/pages/settings/router/paths.js +++ b/src/vue/pages/settings/router/paths.js @@ -49,6 +49,16 @@ export default [ name : __('RSS Content', td) } }, + { + path : '/writing-assistant', + name : 'writing-assistant', + component : loadView('Main'), + meta : { + access : 'aioseo_page_writing_assistant_settings', + name : __('Writing Assistant', td), + label : 'new' + } + }, { path : '/access-control', name : 'access-control', diff --git a/src/vue/pages/settings/views/Main.vue b/src/vue/pages/settings/views/Main.vue index 0c95d6da6..6fe7b8fe0 100644 --- a/src/vue/pages/settings/views/Main.vue +++ b/src/vue/pages/settings/views/Main.vue @@ -18,6 +18,7 @@ import CoreMain from '@/vue/components/common/core/main/Index' import GeneralSettings from './GeneralSettings' import RssContent from './RssContent' import WebmasterTools from './WebmasterTools' +import WritingAssistant from './WritingAssistant' import { __ } from '@/vue/plugins/translations' @@ -36,7 +37,8 @@ export default { CoreMain, GeneralSettings, RssContent, - WebmasterTools + WebmasterTools, + WritingAssistant }, data () { return { diff --git a/src/vue/pages/settings/views/WritingAssistant.vue b/src/vue/pages/settings/views/WritingAssistant.vue new file mode 100644 index 000000000..17a1d5728 --- /dev/null +++ b/src/vue/pages/settings/views/WritingAssistant.vue @@ -0,0 +1,237 @@ + + + + + \ No newline at end of file diff --git a/src/vue/pages/settings/views/partials/WritingAssistant/Cta.vue b/src/vue/pages/settings/views/partials/WritingAssistant/Cta.vue new file mode 100644 index 000000000..046adf945 --- /dev/null +++ b/src/vue/pages/settings/views/partials/WritingAssistant/Cta.vue @@ -0,0 +1,142 @@ + + + + + \ No newline at end of file diff --git a/src/vue/pages/tools/views/partials/Debug.vue b/src/vue/pages/tools/views/partials/Debug.vue index 93d368a42..21df5410b 100644 --- a/src/vue/pages/tools/views/partials/Debug.vue +++ b/src/vue/pages/tools/views/partials/Debug.vue @@ -138,6 +138,7 @@ import CoreNetworkSiteSelector from '@/vue/components/common/core/NetworkSiteSel import CoreSettingsRow from '@/vue/components/common/core/SettingsRow' import DeprecatedOptions from './debug/DeprecatedOptions' import MigrationInfo from './debug/MigrationInfo' +import WritingAssistant from './debug/WritingAssistant' import SvgClose from '@/vue/components/common/svg/Close' import { sprintf } from '@/vue/plugins/translations' @@ -158,6 +159,7 @@ export default { CoreSettingsRow, DeprecatedOptions, MigrationInfo, + WritingAssistant, SvgClose }, props : { @@ -298,6 +300,20 @@ export default { component : 'deprecated-options' } ] + }, + { + slug : 'writing-assistant', + name : 'Writing Assistant', + actions : [ + { + label : 'Writing Assistant', + slug : 'writing-assistant', + shortDescription : 'Resets all users SEOBoost logins.', + longDescription : '', + showModal : false, + component : 'writing-assistant' + } + ] } ] }, diff --git a/src/vue/pages/tools/views/partials/debug/WritingAssistant.vue b/src/vue/pages/tools/views/partials/debug/WritingAssistant.vue new file mode 100644 index 000000000..eac63d824 --- /dev/null +++ b/src/vue/pages/tools/views/partials/debug/WritingAssistant.vue @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/src/vue/plugins/tru-seo/components/postContent.js b/src/vue/plugins/tru-seo/components/postContent.js index 57ced1391..da1ffc0d2 100644 --- a/src/vue/plugins/tru-seo/components/postContent.js +++ b/src/vue/plugins/tru-seo/components/postContent.js @@ -170,9 +170,10 @@ export const getPostContent = () => { /** * Returns the edited post content. * - * @returns {string} Post content + * @param {boolean} ignoreCustomFields Whether to ignore custom fields. + * @returns {string} Post content */ -export const getPostEditedContent = () => { +export const getPostEditedContent = (ignoreCustomFields = false) => { let postContent = '' if (isClassicEditor() && !isPageBuilderEditor()) { if (window.tinyMCE || document.querySelector('#wp-content-wrap.html-active')) { @@ -186,6 +187,7 @@ export const getPostEditedContent = () => { }, 50) } } + if (isBlockEditor()) { postContent = window.wp.data.select('core/editor').getEditedPostContent() postContent = getReusableBlockContent(postContent) @@ -197,7 +199,7 @@ export const getPostEditedContent = () => { } const postEditorStore = usePostEditorStore() - if (postEditorStore.currentPost.descriptionIncludeCustomFields) { + if (!ignoreCustomFields && postEditorStore.currentPost.descriptionIncludeCustomFields) { postContent = postContent + customFieldsContent() } diff --git a/src/vue/plugins/writing-assistant/index.js b/src/vue/plugins/writing-assistant/index.js new file mode 100644 index 000000000..828e89e40 --- /dev/null +++ b/src/vue/plugins/writing-assistant/index.js @@ -0,0 +1,103 @@ +import { useWritingAssistantStore } from '@/vue/stores' +import { isBlockEditor, isClassicEditor } from '@/vue/utils/context' +import { getPostEditedContent } from '@/vue/plugins/tru-seo/components/postContent' +import { debounce } from 'lodash-es' + +export default class WritingAssistant { + content = null + updating = false + writingAssistantStore = null + + constructor () { + if (!window.aioseo.currentPost) { + return + } + + if (!window.aioseo.writingAssistantWatcherSet) { + this.writingAssistantStore = useWritingAssistantStore() + this.initWatchers() + window.aioseo.writingAssistantWatcherSet = true + } + } + + initWatchers () { + if (isBlockEditor()) { + const interval = window.setInterval(() => { + const post = window.wp.data.select('core/editor').getCurrentPost() + if (post.id) { + window.clearInterval(interval) + this.watchBlockEditor() + } + }, 50) + } + + if (isClassicEditor()) { + const mceActiveInterval = window.setInterval(() => { + if (!window.tinyMCE || !window.tinyMCE.activeEditor) { + return + } + window.clearInterval(mceActiveInterval) + this.watchClassicEditor() + }, 50) + } + + window.aioseoBus.$on('writingAssistantAnalyzeContent', () => { + this.updateKeywordCount() + this.updateContentAnalysis() + }) + } + + watchBlockEditor () { + window.wp.data.subscribe(() => { + if (!this.writingAssistantStore.seoBoost.isLoggedIn) { + return + } + this.updateKeywordCount() + this.updateContentAnalysis() + }) + } + + watchClassicEditor () { + window.tinyMCE.get('content').on('keyup', () => { + this.updateKeywordCount() + this.updateContentAnalysis() + }) + + // Do a first run. + this.updateKeywordCount() + this.updateContentAnalysis() + } + + getContent () { + if (!this.writingAssistantStore.hasReport) { + return false + } + + return getPostEditedContent(true) + } + + updateKeywordCount = debounce(() => { + const content = this.getContent() + if (false === content) { + return + } + + this.writingAssistantStore.countContentKeywords(content).finally(() => { + this.updating = false + // TODO: rerun the table sorting. + }) + }, 250) + + updateContentAnalysis = debounce(() => { + const content = this.getContent() + if (false === content) { + return + } + + this.writingAssistantStore.analyzeContent(content).finally(() => { + this.updating = false + }) + }, 1000) +} + +new WritingAssistant() \ No newline at end of file diff --git a/src/vue/standalone/post-settings/views/Main.vue b/src/vue/standalone/post-settings/views/Main.vue index 622b06623..a2410adfb 100644 --- a/src/vue/standalone/post-settings/views/Main.vue +++ b/src/vue/standalone/post-settings/views/Main.vue @@ -635,10 +635,6 @@ export default { border: none; } - .aioseo-description { - margin: 0; - } - .route-fade { &-enter-active, &-leave-active { diff --git a/src/vue/standalone/post-settings/views/partials/general/PageAnalysis.vue b/src/vue/standalone/post-settings/views/partials/general/PageAnalysis.vue index cf51bd114..e4dacf03c 100644 --- a/src/vue/standalone/post-settings/views/partials/general/PageAnalysis.vue +++ b/src/vue/standalone/post-settings/views/partials/general/PageAnalysis.vue @@ -13,7 +13,25 @@ :active="initTab" internal @changed="value => processChangeTab(value)" - /> + > + + +
+
+
+ +
+
+
+ +
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/Sidebar.vue b/src/vue/standalone/writing-assistant/Sidebar.vue new file mode 100644 index 000000000..039033c12 --- /dev/null +++ b/src/vue/standalone/writing-assistant/Sidebar.vue @@ -0,0 +1,88 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/main.js b/src/vue/standalone/writing-assistant/main.js new file mode 100644 index 000000000..09a08e4f6 --- /dev/null +++ b/src/vue/standalone/writing-assistant/main.js @@ -0,0 +1,94 @@ +import { createApp, h } from 'vue' +// import { createRouter, createWebHistory } from 'vue-router' + +import loadPlugins from '@/vue/plugins' +import loadComponents from '@/vue/components/common' +import loadVersionedComponents from '@/vue/components/AIOSEO_VERSION' + +import { + loadPiniaStores, + useWritingAssistantStore +} from '@/vue/stores' + +import { elemLoaded } from '@/vue/utils/elemLoaded' + +import App from './App.vue' +import Sidebar from './Sidebar.vue' +import './registerSidebar' +import '@/vue/plugins/writing-assistant' +import { merge } from 'lodash-es' + +// Router placeholder to prevent errors when using router-link. +/* +const router = createRouter({ + history : createWebHistory(), + routes : [ + { + path : '/', + component : App + } + ] +}) +*/ + +const localCreateApp = (app) => { + app = loadPlugins(app) + app = loadComponents(app) + app = loadVersionedComponents(app) + /* + app.use(router) + + router.app = app + + // Use the pinia store. + */ + // loadPiniaStores(app, router) + loadPiniaStores(app) + const writingAssistantStore = useWritingAssistantStore() + + // eslint-disable-next-line no-undef + const writingAssistantData = aioseoWritingAssistant || null + + // Set initial data. + if (writingAssistantData && !writingAssistantStore.loaded) { + writingAssistantStore.$state = merge({ ...writingAssistantStore.$state }, { ...writingAssistantData || {} }) + // This will set in motion the current user + checks for user info and options. + writingAssistantStore.setUserLoggedIn(writingAssistantData?.seoBoost?.isLoggedIn) + writingAssistantStore.loaded = true + } + + return app +} + +// Create the metabox app. +localCreateApp(createApp({ + name : 'Standalone/WritingAssistant/Metabox', + render : () => h(App) +})).mount('#aioseo-writing-assistant-metabox-app') + +// Create the sidebar app. +let sidebarApp +const loadSidebarApp = () => { + if (sidebarApp) { + sidebarApp.unmount() + } + + sidebarApp = createApp({ + data () { + return { + tableContext : 'post', + screenContext : 'sidebar' + } + }, + render : () => h(Sidebar) + }) + + localCreateApp(sidebarApp).mount('#aioseo-writing-assistant-sidebar > div') +} + +elemLoaded('#aioseo-writing-assistant-sidebar', 'aioseoWritingAssistantSidebarVisible') +document.addEventListener('animationstart', function (event) { + if ('aioseoWritingAssistantSidebarVisible' === event.animationName) { + loadSidebarApp() + } +}, { passive: true }) \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/registerSidebar.js b/src/vue/standalone/writing-assistant/registerSidebar.js new file mode 100644 index 000000000..ff31ef00c --- /dev/null +++ b/src/vue/standalone/writing-assistant/registerSidebar.js @@ -0,0 +1,96 @@ +import { isBlockEditor } from '@/vue/utils/context' +import { __ } from '@wordpress/i18n' + +(function (wp) { + const td = import.meta.env.VITE_TEXTDOMAIN + if (!isBlockEditor()) { + return + } + const registerPlugin = wp.plugins.registerPlugin + const PluginSidebarMoreMenuItem = wp.editPost.PluginSidebarMoreMenuItem + const PluginSidebar = wp.editPost.PluginSidebar + const Fragment = wp.element.Fragment + const el = wp.element.createElement + + const PencilIcon = el('svg', + { + width : 24, + height : 25, + viewBox : '0 0 24 25', + fill : 'none', + xmlns : 'http://www.w3.org/2000/svg' + }, + el('path', + { + d : 'M5 19.4998H6.425L16.2 9.72482L14.775 8.29982L5 18.0748V19.4998ZM3 21.4998V17.2498L16.2 4.07482C16.4 3.89148 16.6208 3.74982 16.8625 3.64982C17.1042 3.54982 17.3583 3.49982 17.625 3.49982C17.8917 3.49982 18.15 3.54982 18.4 3.64982C18.65 3.74982 18.8667 3.89982 19.05 4.09982L20.425 5.49982C20.625 5.68315 20.7708 5.89982 20.8625 6.14982C20.9542 6.39982 21 6.64982 21 6.89982C21 7.16648 20.9542 7.42065 20.8625 7.66232C20.7708 7.90398 20.625 8.12482 20.425 8.32482L7.25 21.4998H3ZM15.475 9.02482L14.775 8.29982L16.2 9.72482L15.475 9.02482Z', + fill : 'white' + } + ), + el('path', + { + d : 'M5.83312 11.4994C5.83312 10.2604 6.26409 9.20986 7.12605 8.34791C7.988 7.48596 9.0385 7.05498 10.2776 7.05498C9.0385 7.05498 7.988 6.624 7.12605 5.76205C6.26409 4.9001 5.83312 3.84959 5.83312 2.61053C5.83312 3.84959 5.40214 4.9001 4.54019 5.76205C3.67823 6.624 2.62773 7.05498 1.38867 7.05498C2.62773 7.05498 3.67823 7.48596 4.54019 8.34791C5.40214 9.20986 5.83312 10.2604 5.83312 11.4994Z', + fill : 'white' + } + ), + el('path', + { + d : 'M11.3891 5.94383C11.3891 5.32431 11.1736 4.79905 10.7426 4.36808C10.3117 3.9371 9.78641 3.72161 9.16688 3.72161C9.78641 3.72161 10.3117 3.50612 10.7426 3.07515C11.1736 2.64417 11.3891 2.11892 11.3891 1.49939C11.3891 2.11892 11.6046 2.64417 12.0356 3.07515C12.4665 3.50612 12.9918 3.72161 13.6113 3.72161C12.9918 3.72161 12.4665 3.9371 12.0356 4.36808C11.6046 4.79905 11.3891 5.32431 11.3891 5.94383Z', + fill : 'white' + } + ), + el('path', + { + d : 'M16.5 21.5002C16.5 20.5245 16.8394 19.6972 17.5182 19.0184C18.197 18.3396 19.0242 18.0002 20 18.0002C19.0242 18.0002 18.197 17.6609 17.5182 16.9821C16.8394 16.3033 16.5 15.476 16.5 14.5002C16.5 15.476 16.1606 16.3033 15.4818 16.9821C14.803 17.6609 13.9758 18.0002 13 18.0002C13.9758 18.0002 14.803 18.3396 15.4818 19.0184C16.1606 19.6972 16.5 20.5245 16.5 21.5002Z', + fill : 'white' + } + ) + ) + + const WritingAssistantButton = el('div', + { id: 'aioseo-writing-assistant-sidebar-button' }, + PencilIcon + ) + const user = window.aioseo.user + + registerPlugin('aioseo-writing-assistant-sidebar', { + render : function () { + if (!user.capabilities.aioseo_page_writing_assistant_settings) { + return null + } + return el(Fragment, {}, + el(PluginSidebarMoreMenuItem, + { + target : 'aioseo-writing-assistant-sidebar', + icon : PencilIcon, + id : 'aioseo-writing-assistant-sidebar-button', + title : 'AIOSEO' // Don't translate this as we need to target it in CSS. + }, + __('AIOSEO Writing Assistant', td) + ), + el(PluginSidebar, + { + name : 'aioseo-writing-assistant-sidebar', + icon : WritingAssistantButton, + title : __('AIOSEO Writing Assistant', td) + }, + el('div', + { id: 'aioseo-writing-assistant-sidebar' }, + el('div', + el('div', + { className: 'aioseo-loading-spinner dark' }, + el('div', + { className: 'double-bounce1' }, + null + ), + el('div', + { className: 'double-bounce2' }, + null + ) + ) + ) + ) + ) + ) + } + }) +})(window.wp) \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/GradeRound.vue b/src/vue/standalone/writing-assistant/views/partials/GradeRound.vue new file mode 100644 index 000000000..3624dd54d --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/GradeRound.vue @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/authenticate/DisconnectModal.vue b/src/vue/standalone/writing-assistant/views/partials/authenticate/DisconnectModal.vue new file mode 100644 index 000000000..6f3b5df17 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/authenticate/DisconnectModal.vue @@ -0,0 +1,127 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/authenticate/Seoboost.vue b/src/vue/standalone/writing-assistant/views/partials/authenticate/Seoboost.vue new file mode 100644 index 000000000..0eec49318 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/authenticate/Seoboost.vue @@ -0,0 +1,160 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/competitor/CouldNotBeAnalyzed.vue b/src/vue/standalone/writing-assistant/views/partials/competitor/CouldNotBeAnalyzed.vue new file mode 100644 index 000000000..d95959187 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/competitor/CouldNotBeAnalyzed.vue @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/competitor/Favicon.vue b/src/vue/standalone/writing-assistant/views/partials/competitor/Favicon.vue new file mode 100644 index 000000000..1a042bb5b --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/competitor/Favicon.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/keyword/Examples.vue b/src/vue/standalone/writing-assistant/views/partials/keyword/Examples.vue new file mode 100644 index 000000000..41e0b9453 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/keyword/Examples.vue @@ -0,0 +1,172 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/keyword/HeadingPresence.vue b/src/vue/standalone/writing-assistant/views/partials/keyword/HeadingPresence.vue new file mode 100644 index 000000000..9d096e1b4 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/keyword/HeadingPresence.vue @@ -0,0 +1,82 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/keyword/Importance.vue b/src/vue/standalone/writing-assistant/views/partials/keyword/Importance.vue new file mode 100644 index 000000000..037b5e176 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/keyword/Importance.vue @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/keyword/New.vue b/src/vue/standalone/writing-assistant/views/partials/keyword/New.vue new file mode 100644 index 000000000..342a7e61d --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/keyword/New.vue @@ -0,0 +1,182 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/keyword/Uses.vue b/src/vue/standalone/writing-assistant/views/partials/keyword/Uses.vue new file mode 100644 index 000000000..553b98be1 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/keyword/Uses.vue @@ -0,0 +1,59 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/seoboost/ReportsRemaining.vue b/src/vue/standalone/writing-assistant/views/partials/seoboost/ReportsRemaining.vue new file mode 100644 index 000000000..9c15cb2fa --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/seoboost/ReportsRemaining.vue @@ -0,0 +1,69 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/sidebar/Competitor.vue b/src/vue/standalone/writing-assistant/views/partials/sidebar/Competitor.vue new file mode 100644 index 000000000..c00a0ec9e --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/sidebar/Competitor.vue @@ -0,0 +1,136 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/sidebar/Keyword.vue b/src/vue/standalone/writing-assistant/views/partials/sidebar/Keyword.vue new file mode 100644 index 000000000..b7b1baeb8 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/sidebar/Keyword.vue @@ -0,0 +1,269 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/summary/Readability.vue b/src/vue/standalone/writing-assistant/views/partials/summary/Readability.vue new file mode 100644 index 000000000..4e06e1331 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/summary/Readability.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/partials/summary/WordCount.vue b/src/vue/standalone/writing-assistant/views/partials/summary/WordCount.vue new file mode 100644 index 000000000..d81cf90b3 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/partials/summary/WordCount.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/Competitors.vue b/src/vue/standalone/writing-assistant/views/report/Competitors.vue new file mode 100644 index 000000000..02c6f110d --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/Competitors.vue @@ -0,0 +1,283 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/CurrentKeyword.vue b/src/vue/standalone/writing-assistant/views/report/CurrentKeyword.vue new file mode 100644 index 000000000..eb6034b99 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/CurrentKeyword.vue @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/Details.vue b/src/vue/standalone/writing-assistant/views/report/Details.vue new file mode 100644 index 000000000..8ab713e57 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/Details.vue @@ -0,0 +1,93 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/DetailsTable.vue b/src/vue/standalone/writing-assistant/views/report/DetailsTable.vue new file mode 100644 index 000000000..84920a23e --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/DetailsTable.vue @@ -0,0 +1,82 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/FirstReport.vue b/src/vue/standalone/writing-assistant/views/report/FirstReport.vue new file mode 100644 index 000000000..617fb48b0 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/FirstReport.vue @@ -0,0 +1,80 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/GenerateReport.vue b/src/vue/standalone/writing-assistant/views/report/GenerateReport.vue new file mode 100644 index 000000000..1c6b4ec34 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/GenerateReport.vue @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/History.vue b/src/vue/standalone/writing-assistant/views/report/History.vue new file mode 100644 index 000000000..2a3559c15 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/History.vue @@ -0,0 +1,127 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/KeywordSearch.vue b/src/vue/standalone/writing-assistant/views/report/KeywordSearch.vue new file mode 100644 index 000000000..c0970d654 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/KeywordSearch.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/Main.vue b/src/vue/standalone/writing-assistant/views/report/Main.vue new file mode 100644 index 000000000..b2e6fe8e9 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/Main.vue @@ -0,0 +1,145 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/OptimizationWizard.vue b/src/vue/standalone/writing-assistant/views/report/OptimizationWizard.vue new file mode 100644 index 000000000..10b668a55 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/OptimizationWizard.vue @@ -0,0 +1,216 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/Overview.vue b/src/vue/standalone/writing-assistant/views/report/Overview.vue new file mode 100644 index 000000000..e553fea94 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/Overview.vue @@ -0,0 +1,210 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/Processing.vue b/src/vue/standalone/writing-assistant/views/report/Processing.vue new file mode 100644 index 000000000..a509a35d1 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/Processing.vue @@ -0,0 +1,80 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/report/YourContent.vue b/src/vue/standalone/writing-assistant/views/report/YourContent.vue new file mode 100644 index 000000000..12cfc4680 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/report/YourContent.vue @@ -0,0 +1,52 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/sidebar/Competitors.vue b/src/vue/standalone/writing-assistant/views/sidebar/Competitors.vue new file mode 100644 index 000000000..44dfa29d5 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/sidebar/Competitors.vue @@ -0,0 +1,144 @@ + + + + + \ No newline at end of file diff --git a/src/vue/standalone/writing-assistant/views/sidebar/OptimizationWizard.vue b/src/vue/standalone/writing-assistant/views/sidebar/OptimizationWizard.vue new file mode 100644 index 000000000..4d1f01819 --- /dev/null +++ b/src/vue/standalone/writing-assistant/views/sidebar/OptimizationWizard.vue @@ -0,0 +1,220 @@ + + + + + \ No newline at end of file diff --git a/src/vue/stores/WritingAssistantSettingsStore.js b/src/vue/stores/WritingAssistantSettingsStore.js new file mode 100644 index 000000000..763962ef7 --- /dev/null +++ b/src/vue/stores/WritingAssistantSettingsStore.js @@ -0,0 +1,110 @@ +import { defineStore } from 'pinia' +import http from '@/vue/utils/http' +import links from '@/vue/utils/links' + +export const useWritingAssistantSettingsStore = defineStore('WritingAssistantSettings', { + state : () => ({ + seoBoost : { + isLoggedIn : false, + loginUrl : '', + userOptions : {} + }, + updating : { + userOptions : false + } + }), + getters : { + loading : (state) => { + return state.updating.userOptions + }, + getCountriesOptions : (state) => { + const countries = [] + if (!state.seoBoost.userOptions?.country_list) { + return countries + } + + Object.keys(state.seoBoost.userOptions?.country_list).forEach(function (key) { + countries.push({ + label : state.seoBoost.userOptions?.country_list[key] + ' (' + state.seoBoost.userOptions?.search_engine_list[key] + ')', + value : key + }) + }) + return countries + }, + userCountryOption : (state) => { + return state.getCountriesOptions.find(option => option.value === state.seoBoost.userOptions.country) || [] + }, + getLanguagesOptions : (state) => { + const languages = [] + if (!state.seoBoost.userOptions?.language_list) { + return languages + } + + Object.keys(state.seoBoost.userOptions?.language_list).forEach(function (key) { + languages.push({ + label : state.seoBoost.userOptions?.language_list[key], + value : key + }) + }) + return languages + }, + userLanguageOption : (state) => { + return state.getLanguagesOptions.find(option => option.value === state.seoBoost.userOptions.language) || [] + } + }, + actions : { + setUserLoggedIn (isLoggedIn) { + this.seoBoost.isLoggedIn = isLoggedIn + this.refreshUserOptions() + }, + disconnect () { + http.post(links.restUrl('writing-assistant/disconnect')) + .send() + .then(response => { + if (response.body.success) { + this.seoBoost.isLoggedIn = false + } + }) + }, + hookSaveUserOptions () { + window.aioseoBus.$on('saving-changes', () => { + this.saveUserOptions() + }) + }, + saveUserOptions () { + if (!this.seoBoost.isLoggedIn) { + return + } + + http.post(links.restUrl('writing-assistant/user-options')) + .send({ + country : this.seoBoost.userOptions.country, + language : this.seoBoost.userOptions.language + }).then(response => { + if (!response.body.success) { + throw new Error(response.body.message) + } + }) + }, + getCountryLabel (country) { + return this.getCountriesOptions.find(option => option.value.toUpperCase() === country.toUpperCase())?.label || '' + }, + getLanguageLabel (language) { + return this.getLanguagesOptions.find(option => option.value === language)?.label || '' + }, + refreshUserOptions () { + if (this.updating.userOptions) { + return + } + + this.updating.userOptions = true + http.get('/writing-assistant/user-options') + .then(result => { + if (!result.body?.error) { + this.seoBoost.userOptions = result.body || {} + } + this.updating.userOptions = false + }) + } + } +}) \ No newline at end of file diff --git a/src/vue/stores/index.js b/src/vue/stores/index.js index fabdd7c93..d8cc534e7 100644 --- a/src/vue/stores/index.js +++ b/src/vue/stores/index.js @@ -27,9 +27,11 @@ import { useSetupWizardStore } from '@/vue/stores/SetupWizardStore' import { useTagsStore } from '@/vue/stores/TagsStore' import { useToolsStore } from '@/vue/stores/ToolsStore' import { useTruSeoHighlighterStore } from '@/vue/stores/TruSeoHighlighterStore' +import { useWritingAssistantSettingsStore } from '@/vue/stores/WritingAssistantSettingsStore' -// Standolone stores. +// Standalone stores. import { useTableOfContentsStore } from '@/vue/stores/standalones/TableOfContentsStore' +import { useWritingAssistantStore } from '@/vue/stores/standalones/WritingAssistantStore' // Integration Stores. import { useSemrushStore } from '@/vue/stores/integrations/SemrushStore' @@ -75,6 +77,7 @@ const loadPiniaStores = (app, router = null, loadStoresCallback = () => {}) => { const settingsStore = useSettingsStore() const tagsStore = useTagsStore() const wpCodeStore = useWpCodeStore() + const writingAssistantSettingsStore = useWritingAssistantSettingsStore() // Initial network data. const networkData = { @@ -90,25 +93,26 @@ const loadPiniaStores = (app, router = null, loadStoresCallback = () => {}) => { optionsStore.networkOptions = merge({ ...optionsStore.networkOptions }, { ...aioseo.networkOptions || {} }) // Other stores. - addonsStore.addons = merge([ ...addonsStore.addons ], [ ...aioseo.addons || [] ]) - backupsStore.backups = merge([ ...backupsStore.backups ], [ ...aioseo.backups || [] ]) - backupsStore.networkBackups = merge({ ...backupsStore.networkBackups }, { ...aioseo.data?.network?.backups || {} }) - helpPanelStore.$state = merge({ ...helpPanelStore.$state }, { ...aioseo.helpPanel || {} }) - indexNowStore.$state = merge({ ...indexNowStore.$state }, { ...aioseo.indexNow || {} }) - keywordRankTrackerStore.$state = merge({ ...keywordRankTrackerStore.$state }, { ...aioseo.keywordRankTracker || {} }) - licenseStore.license = merge({ ...licenseStore.license }, { ...aioseo.license || {} }) - linkAssistantStore.$state = merge({ ...linkAssistantStore.$state }, { ...aioseo.linkAssistant || {} }) - localSeoStore.$state = merge({ ...localSeoStore.$state }, { ...aioseo.localBusiness || {} }) - networkStore.networkData = merge({ ...networkStore.networkData }, { ...networkData }) - notificationsStore.$state = merge({ ...notificationsStore.$state }, { ...aioseo.notifications || {} }) - pluginsStore.plugins = merge({ ...pluginsStore.plugins }, { ...aioseo.plugins || {} }) - postEditorStore.currentPost = merge({ ...postEditorStore.currentPost }, { ...aioseo.currentPost || {} }) - redirectsStore.$state = merge({ ...redirectsStore.$state }, { ...aioseo.redirects || {} }) - searchStatisticsStore.$state = merge({ ...searchStatisticsStore.$state }, { ...aioseo.searchStatistics || {} }) - seoRevisionsStore.$state = merge({ ...seoRevisionsStore.$state }, { ...aioseo.seoRevisions || {} }) - settingsStore.settings = merge({ ...settingsStore.settings }, { ...aioseo.settings || {} }) - settingsStore.userProfile = merge({ ...settingsStore.userProfile }, { ...aioseo.userProfile || {} }) - tagsStore.$state = merge({ ...tagsStore.$state }, { ...aioseo.tags || {} }) + addonsStore.addons = merge([ ...addonsStore.addons ], [ ...aioseo.addons || [] ]) + backupsStore.backups = merge([ ...backupsStore.backups ], [ ...aioseo.backups || [] ]) + backupsStore.networkBackups = merge({ ...backupsStore.networkBackups }, { ...aioseo.data?.network?.backups || {} }) + helpPanelStore.$state = merge({ ...helpPanelStore.$state }, { ...aioseo.helpPanel || {} }) + indexNowStore.$state = merge({ ...indexNowStore.$state }, { ...aioseo.indexNow || {} }) + keywordRankTrackerStore.$state = merge({ ...keywordRankTrackerStore.$state }, { ...aioseo.keywordRankTracker || {} }) + licenseStore.license = merge({ ...licenseStore.license }, { ...aioseo.license || {} }) + linkAssistantStore.$state = merge({ ...linkAssistantStore.$state }, { ...aioseo.linkAssistant || {} }) + localSeoStore.$state = merge({ ...localSeoStore.$state }, { ...aioseo.localBusiness || {} }) + networkStore.networkData = merge({ ...networkStore.networkData }, { ...networkData }) + notificationsStore.$state = merge({ ...notificationsStore.$state }, { ...aioseo.notifications || {} }) + pluginsStore.plugins = merge({ ...pluginsStore.plugins }, { ...aioseo.plugins || {} }) + postEditorStore.currentPost = merge({ ...postEditorStore.currentPost }, { ...aioseo.currentPost || {} }) + redirectsStore.$state = merge({ ...redirectsStore.$state }, { ...aioseo.redirects || {} }) + searchStatisticsStore.$state = merge({ ...searchStatisticsStore.$state }, { ...aioseo.searchStatistics || {} }) + seoRevisionsStore.$state = merge({ ...seoRevisionsStore.$state }, { ...aioseo.seoRevisions || {} }) + settingsStore.settings = merge({ ...settingsStore.settings }, { ...aioseo.settings || {} }) + settingsStore.userProfile = merge({ ...settingsStore.userProfile }, { ...aioseo.userProfile || {} }) + tagsStore.$state = merge({ ...tagsStore.$state }, { ...aioseo.tags || {} }) + writingAssistantSettingsStore.$state = merge({ ...writingAssistantSettingsStore.$state }, { ...aioseo.writingAssistantSettings || {} }) // Integration stores. if (aioseo.integrations?.wpcode) { @@ -233,5 +237,7 @@ export { useTagsStore, useToolsStore, useTruSeoHighlighterStore, + useWritingAssistantStore, + useWritingAssistantSettingsStore, useWpCodeStore } \ No newline at end of file diff --git a/src/vue/stores/standalones/WritingAssistantStore.js b/src/vue/stores/standalones/WritingAssistantStore.js new file mode 100644 index 000000000..bbc2aebdc --- /dev/null +++ b/src/vue/stores/standalones/WritingAssistantStore.js @@ -0,0 +1,395 @@ +import { defineStore } from 'pinia' +import { sortHelper } from '@/vue/utils/sort' +import escapeRegex from '@/app/tru-seo/analyzer/researches/helpers/escapeRegex' +import { getPostEditedTitle } from '@/vue/plugins/tru-seo/components/postTitle' +import { getPostExcerpt } from '@/vue/plugins/tru-seo/components/postExcerpt' +import http from '@/vue/utils/http' +import { __ } from '@/vue/plugins/translations' + +const td = import.meta.env.VITE_TEXTDOMAIN + +export const useWritingAssistantStore = defineStore('WritingAssistantStore', { + state : () => ({ + loaded : false, + keywordText : '', + report : {}, + contentAnalysis : {}, + analyzingContent : false, + error : null, + processing : false, + polling : false, + postId : 0, + progress : { + percent : 0, + text : '' + }, + progressRunning : false, + sortFilters : { + optimizationWizard : { + slug : 'importance', + sortDir : 'desc', + perPage : 10, + page : 1, + search : '' + }, + competitors : { + slug : 'googlePosition', + sortDir : 'asc', + perPage : 10, + page : 1, + search : '' + } + }, + seoBoost : { + isLoggedIn : false, + loginUrl : '' + }, + userInfo : {}, + updating : { + userInfo : false, + reportHistory : false + }, + reportHistory : [] + }), + getters : { + isProcessing : (state) => { + // Check if we are processing. + if (state.report?.uuid && !state.error) { + return 100 !== parseInt(state.report?.progress) + } + + return state.processing + }, + processDone : (state) => { + return 100 === parseInt(state.report?.progress) + }, + getCompetitors : (state) => { + return state.report.competitors.competitors || [] + }, + getCompetitorsSearched : (state) => { + const searchString = state.sortFilters.competitors.search.toLowerCase() + return state.getCompetitors.filter(competitor => { + return competitor.url.toLowerCase().includes(searchString) || + competitor.title.toLowerCase().includes(searchString) + }) + }, + getCompetitorsPaged : (state) => { + const from = (state.sortFilters.competitors.page - 1) * state.sortFilters.competitors.perPage + const to = from + state.sortFilters.competitors.perPage + return state.getCompetitorsSearched.slice(from, to) + }, + getCompetitorsPages : (state) => { + return Math.ceil(state.getCompetitorsSearched.length / state.sortFilters.competitors.perPage) + }, + getKeywordCompetitorsSummary : (state) => { + return state.report.competitors.summary || [] + }, + getKeywords : (state) => { + return state.report.keywords || [] + }, + getKeywordsSearched : (state) => { + const searchString = state.sortFilters.optimizationWizard.search.toLowerCase() + return state.getKeywords.filter(keyword => { + return keyword.text.toLowerCase().includes(searchString) + }) + }, + getKeywordsPaged : (state) => { + const from = (state.sortFilters.optimizationWizard.page - 1) * state.sortFilters.optimizationWizard.perPage + const to = from + state.sortFilters.optimizationWizard.perPage + return state.getKeywordsSearched.slice(from, to) + }, + getKeywordsPages : (state) => { + return Math.ceil(state.getKeywordsSearched.length / state.sortFilters.optimizationWizard.perPage) + }, + getContentAnalysis : (state) => { + return state.contentAnalysis || [] + }, + hasReport : (state) => { + return state.report && 100 === parseInt(state.report?.progress) + } + }, + actions : { + processKeyword ({ keyword, country, language }) { + if (this.processing) { + return + } + + this.processing = true + this.setReportProgress(0) + this.error = null + + http.post('/writing-assistant/process') + .send({ + postId : this.postId, + keyword : keyword, + country : country, + language : language + }) + .then(result => { + if (false === result.body.success) { + this.processing = false + this.error = result.body.error + return + } + + this.keywordText = keyword + this.setReportProgress(5, __('processing keyword', td) + '...') + + // Reset tables filters and pagination. + this.resetTables() + + // Start polling results. + this.pollKeyword() + }) + }, + pollKeyword () { + this.processing = true + this.polling = true + this.error = null + + // Get keywords and add to the state. + http.get('/writing-assistant/keyword/' + this.postId) + .then(result => { + if (false === result.body?.success) { + this.processing = false + this.polling = false + this.error = result.body.error + this.report = {} + return + } + + this.startReportProgress(result.body.progress) + + // Trigger a keyword count and user info update. + if (100 === parseInt(result.body.progress)) { + this.updateUserInfo() + this.updateReportHistory() + this.report = result.body || [] + + // Give the frontend time to show the loading bar. + setTimeout(() => { + this.polling = false + this.processing = false + window.aioseoBus.$emit('writingAssistantAnalyzeContent') + }, 2000) + + return + } + + // Polling progress. + setTimeout(() => { + this.pollKeyword() + }, 8000) + }) + }, + openReport (keyword, country, language) { + this.processKeyword({ + keyword, + country, + language + }) + }, + async analyzeContent (content) { + if (this.analyzingContent) { + return Promise.resolve() + } + + this.analyzingContent = true + // Send post content to analysis. + return http.post('/writing-assistant/content-analysis') + .send({ + title : getPostEditedTitle(), + description : getPostExcerpt(), + content : content, + postId : this.postId + }) + .then(result => { + this.analyzingContent = false + this.contentAnalysis = result.body || {} + }) + }, + async countContentKeywords (payload) { + if (!payload || !this.getKeywords.length) { + return Promise.resolve() + } + + // Remove all tags. + payload = payload.replace(/(<([^>]+)>)/gi, ' ') + + this.getKeywords.forEach((keyword) => { + // Pattern searches for the keyword without any boundaries before or after it. + keyword.contentCount = payload.match(new RegExp('\\b' + escapeRegex(keyword.text) + '\\b', 'gmi'))?.length || 0 + }) + + // Sort the keywords again. + if ('contentCount' === this.sortFilters.optimizationWizard.slug) { + this.doOptimizationWizardSort({ + slug : this.sortFilters.optimizationWizard.slug, + order : this.sortFilters.optimizationWizard.sortDir + }) + } + + return Promise.resolve() + }, + doOptimizationWizardSort (payload) { + // Forced order. + let order = payload?.order || 'asc' + + // If we are sorting by the same column, reverse the order. + if (!payload?.order && payload.slug === this.sortFilters.optimizationWizard.slug) { + order = 'asc' === this.sortFilters.optimizationWizard.sortDir ? 'desc' : 'asc' + } + + this.sortFilters.optimizationWizard.slug = payload.slug + this.sortFilters.optimizationWizard.sortDir = order + this.getKeywords.sort((a, b) => { + return sortHelper(a, b, this.sortFilters.optimizationWizard.slug, this.sortFilters.optimizationWizard.sortDir) + }) + }, + doOptimizationWizardPerPage (perPage) { + this.sortFilters.optimizationWizard.page = 1 + this.sortFilters.optimizationWizard.perPage = perPage + }, + doOptimizationWizardSearch (search) { + this.sortFilters.optimizationWizard.page = 1 + this.sortFilters.optimizationWizard.search = search + }, + doCompetitorSort (payload) { + let order = 'asc' + if (payload.slug === this.sortFilters.competitors.slug) { + order = 'asc' === this.sortFilters.competitors.sortDir ? 'desc' : 'asc' + } + + this.sortFilters.competitors.slug = payload.slug + this.sortFilters.competitors.sortDir = order + + this.getCompetitors.sort((a, b) => { + return sortHelper(a, b, this.sortFilters.competitors.slug, this.sortFilters.competitors.sortDir) + }) + }, + doCompetitorPerPage (perPage) { + this.sortFilters.competitors.page = 1 + this.sortFilters.competitors.perPage = perPage + }, + doCompetitorSearch (search) { + this.sortFilters.competitors.page = 1 + this.sortFilters.competitors.search = search + }, + newReport () { + this.report = {} + this.contentAnalysis = {} + this.error = null + }, + updateUserInfo () { + if (this.updating.userInfo) { + return + } + + this.updating.userInfo = true + http.get('/writing-assistant/user-info') + .then(result => { + if (!result.body?.error) { + this.userInfo = result.body || {} + } + this.updating.userInfo = false + }) + }, + updateReportHistory () { + if (this.updating.reportHistory) { + return + } + + this.updating.reportHistory = true + http.get('/writing-assistant/report-history') + .then(result => { + if (!result.body?.error) { + this.reportHistory = result.body || {} + } + this.updating.reportHistory = false + }) + }, + setUserLoggedIn (isLoggedIn) { + this.seoBoost.isLoggedIn = isLoggedIn + + if (isLoggedIn) { + this.updateUserInfo() + if (this.isProcessing) { + this.pollKeyword() + } + } + }, + async startReportProgress (progress) { + // If we are already running a progress, don't start another one. + if (this.progressRunning) { + return + } + + this.progressRunning = true + + const randBetween = (min, max) => Math.floor(Math.random() * (max - min + 1) + min) + + const loadingStages = [ + { percent: randBetween(5, 10), duration: 2000, text: __('querying search engines', td) + '...' }, + { percent: randBetween(15, 20), duration: 5000, text: __('fetching serps', td) + '...' }, + { percent: randBetween(25, 30), duration: 6000, text: __('analyzing serps', td) + '...' }, + { percent: randBetween(35, 40), duration: 10000, text: __('fetching competitor data', td) + '...' }, + { percent: randBetween(45, 50), duration: 7000, text: __('fetching keywords data', td) + '...' }, + { percent: randBetween(55, 60), duration: 7000, text: __('calculating keywords heading presence', td) + '...' }, + { percent: randBetween(65, 70), duration: 7000, text: __('calculating keywords use suggestions', td) + '...' }, + { percent: randBetween(75, 80), duration: 7000, text: __('calculating keywords importance', td) + '...' }, + { percent: randBetween(85, 90), duration: 7000, text: __('fetching keywords examples', td) + '...' }, + { percent: 100, duration: 1000, text: __('fetching report', td) + '...' } + ] + + let prevStageDuration = 0 + + for (const stage of loadingStages.filter(stage => stage.percent >= progress)) { + await new Promise(resolve => { + setTimeout(() => { + // Set lower than 100 so the progress bar feels like it's finishing to load. + if (100 === stage.percent) { + stage.percent = 97 + this.progressRunning = false + } + + this.setReportProgress(stage.percent, stage.text) + + // Save fake progress in case user reloads page. + if (100 !== progress) { + http.post('/writing-assistant/set-report-progress') + .send({ + postId : this.postId, + progress : stage.percent + }).then(result => result.body) + } + + resolve() + }, prevStageDuration) + prevStageDuration = stage.duration + }) + } + }, + setReportProgress (percent, text = '', timeout = 0) { + setTimeout(() => { + this.progress.percent = percent + this.progress.text = text + }, timeout) + }, + resetTables () { + this.sortFilters.optimizationWizard = { + slug : 'importance', + sortDir : 'desc', + perPage : 10, + page : 1, + search : '' + } + this.sortFilters.competitors = { + slug : 'googlePosition', + sortDir : 'asc', + perPage : 10, + page : 1, + search : '' + } + } + } +}) \ No newline at end of file diff --git a/src/vue/utils/csv.js b/src/vue/utils/csv.js new file mode 100644 index 000000000..96e2eee71 --- /dev/null +++ b/src/vue/utils/csv.js @@ -0,0 +1,11 @@ +export const arrayToCsv = (value) => { + return value.map(function (d) { + if ('object' === typeof d) { + return JSON.stringify(Object.values(d)) + } + + return d + }) + .join('\n') + .replace(/(^\[)|(\]$)/mg, '') +} \ No newline at end of file diff --git a/src/vue/utils/download.js b/src/vue/utils/download.js new file mode 100644 index 000000000..7c41e581b --- /dev/null +++ b/src/vue/utils/download.js @@ -0,0 +1,9 @@ +export const downloadFile = (content, fileName) => { + const blob = new Blob([ content ]) + const link = document.createElement('a') + + link.href = URL.createObjectURL(blob) + link.download = fileName + link.click() + URL.revokeObjectURL(link.href) +} \ No newline at end of file diff --git a/src/vue/utils/helpers.js b/src/vue/utils/helpers.js index 653918d2a..c1295bfd9 100644 --- a/src/vue/utils/helpers.js +++ b/src/vue/utils/helpers.js @@ -25,7 +25,8 @@ export const decodeSpecialChars = string => { '&' : '&', '<' : '<', '>' : '>', - '"' : '"' + '"' : '"', + ' ' : ' ' } Object.entries(charMap).forEach(([ key, value ]) => { diff --git a/src/vue/utils/http.js b/src/vue/utils/http.js index 5a4ba24d6..7d9644576 100644 --- a/src/vue/utils/http.js +++ b/src/vue/utils/http.js @@ -1,8 +1,13 @@ import superagent from 'superagent' +import links from '@/vue/utils/links' export default superagent.agent() .set('X-WP-Nonce', window?.aioseo?.nonce) .use(req => { + if ('/' === req.url[0]) { + req.url = links.unTrailingSlashIt(links.restUrl(req.url)) + } + req.on('response', response => { if (401 === response.status || 403 === response.status) { console.error(response) diff --git a/src/vue/utils/links.js b/src/vue/utils/links.js index 39e909465..4a0f4f3ef 100644 --- a/src/vue/utils/links.js +++ b/src/vue/utils/links.js @@ -109,7 +109,8 @@ const docLinks = { eeatAuthorBioInjection : `${marketingSite}docs/adding-author-seo-e-e-a-t-to-your-site/#aioseo-automatically-displaying-the-author-excerpt`, queryArgMonitor : `${marketingSite}docs/using-the-query-arg-monitoring-in-all-in-one-seo/`, businessPhoneNumber : `${marketingSite}docs/best-business-phone-services`, - keywordRankTracker : `${marketingSite}docs/using-the-keyword-rank-tracker-feature-in-search-statistics/` + keywordRankTracker : `${marketingSite}docs/using-the-keyword-rank-tracker-feature-in-search-statistics/`, + writingAssistantHowToUse : `${marketingSite}docs/how-to-use-the-writing-assistant-in-aioseo/` } const upsellLinks = { diff --git a/src/vue/utils/sort.js b/src/vue/utils/sort.js new file mode 100644 index 000000000..7b67a3ed7 --- /dev/null +++ b/src/vue/utils/sort.js @@ -0,0 +1,25 @@ +function sortHelper (a, b, sortColumn, sortDir) { + if ('number' === typeof a[sortColumn]) { + if ('asc' === sortDir) { + return a[sortColumn] - b[sortColumn] + } + if ('desc' === sortDir) { + return b[sortColumn] - a[sortColumn] + } + } + + if ('string' === typeof a[sortColumn]) { + if ('asc' === sortDir) { + return a[sortColumn].localeCompare(b[sortColumn], 'en', { sensitivity: 'base' }) + } + if ('desc' === sortDir) { + return b[sortColumn].localeCompare(a[sortColumn], 'en', { sensitivity: 'base' }) + } + } + + return 0 +} + +export { + sortHelper +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index feb7a4bd8..9c312366e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -72,6 +72,7 @@ const getStandalones = () => { 'setup-wizard' : './src/vue/standalone/setup-wizard/main.js', 'user-profile-tab' : './src/vue/standalone/user-profile-tab/main.js', 'wp-notices' : './src/vue/standalone/wp-notices/main.js', + 'writing-assistant' : './src/vue/standalone/writing-assistant/main.js', // Page builder integrations. avada : './src/vue/standalone/page-builders/avada/main.js', divi : './src/vue/standalone/page-builders/divi/main.js',