diff --git a/README.md b/README.md index 1c56b8c..eba587f 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -# aioseo-duplicate-post \ No newline at end of file +# Duplicate Post by AIOSEO + +## Project setup +``` +npm install +``` + +### Compiles and hot-reloads for development +``` +npm run dev +``` + +### Compiles and minifies for production +``` +npm run build +``` + +### Customize configuration +See [Configuration Reference](https://vitejs.dev/config/). \ No newline at end of file diff --git a/aioseo-duplicate-post.php b/aioseo-duplicate-post.php new file mode 100644 index 0000000..36d63eb --- /dev/null +++ b/aioseo-duplicate-post.php @@ -0,0 +1,78 @@ +. + * + * @since 1.0.0 + * @author All in One SEO + * @license GPL-2.0+ + * @copyright Copyright (c) 2023, All in One SEO + */ + +// Exit if accessed directly. +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! defined( 'AIOSEO_DUPLICATE_POST_PHP_VERSION_DIR' ) ) { + define( 'AIOSEO_DUPLICATE_POST_PHP_VERSION_DIR', basename( dirname( __FILE__ ) ) ); +} + +require_once dirname( __FILE__ ) . '/app/init/init.php'; + +// Check if this plugin should be disabled. +if ( aioseo_duplicate_post_is_plugin_disabled() ) { + return; +} + +require_once dirname( __FILE__ ) . '/app/init/notices.php'; + +// We require PHP 7.0 or higher for the whole plugin to work. +if ( version_compare( PHP_VERSION, '7.0', '<' ) ) { + add_action( 'admin_notices', 'aioseo_plugin_php_notice' ); + + // Do not process the plugin code further. + return; +} + +// We require WP 5.3+ for the whole plugin to work. +global $wp_version; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps +if ( version_compare( $wp_version, '5.3', '<' ) ) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps + add_action( 'admin_notices', 'aioseo_plugin_wordpress_notice' ); + + // Do not process the plugin code further. + return; +} + +// Plugin constants. +if ( ! defined( 'AIOSEO_DUPLICATE_POST_DIR' ) ) { + define( 'AIOSEO_DUPLICATE_POST_DIR', __DIR__ ); +} +if ( ! defined( 'AIOSEO_DUPLICATE_POST_FILE' ) ) { + define( 'AIOSEO_DUPLICATE_POST_FILE', __FILE__ ); +} + +// Define the class and the function. +require_once dirname( __FILE__ ) . '/app/DuplicatePost.php'; + +aioseoDuplicatePost(); \ No newline at end of file diff --git a/app/Admin/Admin.php b/app/Admin/Admin.php new file mode 100644 index 0000000..c7a2188 --- /dev/null +++ b/app/Admin/Admin.php @@ -0,0 +1,439 @@ + 'src/vue/pages/{page}/main.js' + ]; + + /** + * The plugin basename. + * + * @since 1.0.0 + * + * @var string + */ + public $plugin = ''; + + /** + * The list of pages. + * + * @since 1.0.0 + * + * @var array + */ + private $pages = []; + + /** + * Class constructor. + * + * @since 1.0.0 + */ + public function __construct() { + if ( ! is_admin() ) { + return; + } + + add_action( 'admin_menu', [ $this, 'registerMenu' ] ); + add_action( 'admin_menu', [ $this, 'hideScheduledActionsMenu' ], 999 ); + add_filter( 'language_attributes', [ $this, 'addDirAttribute' ], 3000 ); + + // add_filter( 'plugin_row_meta', [ $this, 'registerRowMeta' ], 10, 2 ); + add_filter( 'plugin_action_links_' . AIOSEO_DUPLICATE_POST_PLUGIN_BASENAME, [ $this, 'registerActionLinks' ], 10, 2 ); + + add_action( 'admin_footer', [ $this, 'addAioseoModalPortal' ] ); + } + + /** + * Checks whether the current page is a Duplicate Post page. + * + * @since 1.0.0 + * + * @return bool Whether the current page is a Duplicate Post page. + */ + public function isDuplicatePostPage() { + return ! empty( $this->currentPage ); + } + + /** + * Add the dir attribute to the HTML tag. + * + * @since 1.0.0 + * + * @param string $output The HTML language attribute. + * @return string The modified HTML language attribute. + */ + public function addDirAttribute( $output ) { + if ( is_rtl() || preg_match( '/dir=[\'"](ltr|rtl|auto)[\'"]/i', $output ) ) { + return $output; + } + + return 'dir="ltr" ' . $output; + } + + /** + * Registers the menu. + * + * @since 1.0.0 + * + * @return void + */ + public function registerMenu() { + $hook = add_menu_page( + __( 'Duplicate Post', 'duplicate-post-page-aioseo' ), + __( 'Duplicate Post', 'duplicate-post-page-aioseo' ), + 'aioseo_duplicate_post_settings', + $this->pageSlug, + [ $this, 'renderMenuPage' ], + 'data:image/svg+xml;base64,' . base64_encode( aioseoDuplicatePost()->helpers->icon() ) + ); + + add_action( "load-{$hook}", [ $this, 'checkCurrentPage' ] ); + + $this->registerMenuPages(); + } + + /** + * Renders the element that we mount our Vue UI on. + * + * @since 1.0.0 + * + * @return void + */ + public function renderMenuPage() { + echo '
'; + } + + /** + * Registers our menu pages. + * + * @since 1.0.0 + * + * @return void + */ + public function registerMenuPages() { + $hooks = []; + + $hooks[] = add_submenu_page( + $this->pageSlug, + __( 'Settings', 'duplicate-post-page-aioseo' ), + __( 'Settings', 'duplicate-post-page-aioseo' ), + 'aioseo_duplicate_post_settings', + $this->pageSlug, + [ $this, 'renderMenuPage' ] + ); + + $this->pages[] = $this->pageSlug; + + $hooks[] = add_submenu_page( + $this->pageSlug, + __( 'About Us', 'duplicate-post-page-aioseo' ), + __( 'About Us', 'duplicate-post-page-aioseo' ), + 'aioseo_duplicate_post_settings', + $this->pageSlug . '-about', + [ $this, 'renderMenuPage' ] + ); + + $this->pages[] = $this->pageSlug . '-about'; + + foreach ( $hooks as $hook ) { + add_action( "load-{$hook}", [ $this, 'checkCurrentPage' ] ); + } + } + + /** + * Checks if the current page is a Duplicate Post page and if so, starts enqueing the relevant assets. + * + * @since 1.0.0 + * + * @return void + */ + public function checkCurrentPage() { + global $admin_page_hooks; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps + $currentScreen = function_exists( 'get_current_screen' ) ? get_current_screen() : false; + + if ( empty( $currentScreen->id ) || empty( $admin_page_hooks ) ) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps + return; + } + + $pages = [ + 'about', + 'duplicator', + ]; + + foreach ( $pages as $page ) { + $addScripts = false; + + if ( 'toplevel_page_duplicate-post' === $currentScreen->id ) { + $page = 'duplicator'; + $addScripts = true; + } + + if ( strpos( $currentScreen->id, 'duplicate-post-' . $page ) !== false ) { + $addScripts = true; + } + + if ( ! $addScripts ) { + continue; + } + + // We don't want other plugins adding notices to our screens. Let's clear them out here. + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'all_admin_notices' ); + + $this->currentPage = $page; + add_action( 'admin_enqueue_scripts', [ $this, 'enqueueMenuAssets' ], 11 ); + + // TODO: Add this in once the final slug is known. + // add_filter( 'admin_footer_text', [ $this, 'addFooterText' ] ); + + break; + } + } + + /** + * Enqueues our menu assets, based on the current page. + * + * @since 1.0.0 + * + * @return void + */ + public function enqueueMenuAssets() { + if ( ! $this->currentPage ) { + return; + } + + $scriptHandle = str_replace( '{page}', $this->currentPage, $this->assetSlugs['pages'] ); + aioseoDuplicatePost()->core->assets->load( $scriptHandle, [], aioseoDuplicatePost()->helpers->getVueData( $this->currentPage ) ); + } + + /** + * Hides the Scheduled Actions menu. + * + * @since 1.0.0 + * + * @return void + */ + public function hideScheduledActionsMenu() { + // Don't hide it for developers when the main plugin isn't active. + if ( defined( 'AIOSEO_DUPLICATE_POST_DEV' ) && ! function_exists( 'aioseo' ) ) { + return; + } + + global $submenu; + if ( ! isset( $submenu['tools.php'] ) ) { + return; + } + + foreach ( $submenu['tools.php'] as $index => $props ) { + if ( ! empty( $props[2] ) && 'action-scheduler' === $props[2] ) { + unset( $submenu['tools.php'][ $index ] ); + + return; + } + } + } + + /** + * Registers our row meta for the plugins page. + * + * @since 1.0.0 + * + * @param array $actions List of existing actions. + * @param string $pluginFile The plugin file. + * @return array List of action links. + */ + public function registerRowMeta( $actions, $pluginFile ) { + // TODO: Add this once the plugin is launched. + $actionLinks = [ + 'settings' => [ + // Translators: This is an action link users can click to open a feature request. + 'label' => esc_html__( 'Suggest a Feature', 'duplicate-post-page-aioseo' ), + 'url' => aioseoDuplicatePost()->helpers->utmUrl( AIOSEO_DUPLICATE_POST_MARKETING_URL . 'dp-suggest-a-feature/', 'plugin-row-meta', 'Feature' ), + ] + ]; + + return $this->parseActionLinks( $actions, $pluginFile, $actionLinks ); + } + + /** + * Registers our action links for the plugins page. + * + * @since 1.0.0 + * + * @param array $actions List of existing actions. + * @param string $pluginFile The plugin file. + * @return array List of action links. + */ + public function registerActionLinks( $actions, $pluginFile ) { + $actionLinks = [ + 'support' => [ + // Translators: This is an action link users can click to open our support. + 'label' => esc_html__( 'Support', 'duplicate-post-page-aioseo' ), + 'url' => aioseoDuplicatePost()->helpers->utmUrl( AIOSEO_DUPLICATE_POST_MARKETING_URL . 'plugin/duplicate-post-support', 'plugin-action-links', 'Documentation' ), + ], + 'docs' => [ + // Translators: This is an action link users can click to open our documentation page. + 'label' => esc_html__( 'Documentation', 'duplicate-post-page-aioseo' ), + 'url' => aioseoDuplicatePost()->helpers->utmUrl( AIOSEO_DUPLICATE_POST_MARKETING_URL . 'doc-categories/duplicate-post/', 'plugin-action-links', 'Documentation' ), + ] + ]; + + if ( isset( $actions['edit'] ) ) { + unset( $actions['edit'] ); + } + + return $this->parseActionLinks( $actions, $pluginFile, $actionLinks, 'before' ); + } + + /** + * Parses the action links. + * + * @since 1.0.0 + * + * @param array $actions The actions. + * @param string $pluginFile The plugin file. + * @param array $actionLinks The action links. + * @param string $position The position. + * @return array The parsed actions. + */ + private function parseActionLinks( $actions, $pluginFile, $actionLinks = [], $position = 'after' ) { + if ( empty( $this->plugin ) ) { + $this->plugin = AIOSEO_DUPLICATE_POST_PLUGIN_BASENAME; + } + + if ( $this->plugin === $pluginFile && ! empty( $actionLinks ) ) { + foreach ( $actionLinks as $key => $value ) { + $link = [ + $key => '' . $value['label'] . '' + ]; + + $actions = 'after' === $position ? array_merge( $actions, $link ) : array_merge( $link, $actions ); + } + } + + return $actions; + } + + /** + * Add the div for the modal portal. + * + * @since 1.0.0 + * + * @return void + */ + public function addAioseoModalPortal() { + if ( ! function_exists( 'aioseo' ) ) { + echo '
'; + } + } + + /** + * Checks whether the current page is a Duplicate Post menu page. + * + * @since 1.0.0 + * + * @return bool Whether the current page is a Duplicate Post menu page. + */ + public function isDuplicatePostScreen() { + $currentScreen = aioseoDuplicatePost()->helpers->getCurrentScreen(); + if ( empty( $currentScreen->id ) ) { + return false; + } + + $adminPages = array_keys( $this->pages ); + $adminPages = array_map( function( $slug ) { + if ( 'aioseo' === $slug ) { + return 'toplevel_page_duplicate-post'; + } + + return 'duplicate-post_page_' . $slug; + }, $adminPages ); + + return in_array( $currentScreen->id, $adminPages, true ); + } + + /** + * Add footer text to the WordPress admin screens. + * + * @since 1.0.0 + * + * @return string The footer text. + */ + public function addFooterText() { + $linkText = esc_html__( 'Give us a 5-star rating!', 'duplicate-post-page-aioseo' ); + $href = 'https://wordpress.org/support/plugin/duplicate-post/reviews/?filter=5#new-post'; + + $link1 = sprintf( + '★★★★★', + $href, + $linkText + ); + + $link2 = sprintf( + 'WordPress.org', + $href, + $linkText + ); + + printf( + // Translators: 1 - The plugin name ("Duplicate Post"), - 2 - This placeholder will be replaced with star icons, - 3 - "WordPress.org" - 4 - The plugin name ("Duplicate Post"). + esc_html__( 'Please rate %1$s %2$s on %3$s to help us spread the word. Thank you!', 'duplicate-post-page-aioseo' ), + sprintf( '%1$s', esc_html( AIOSEO_DUPLICATE_POST_PLUGIN_NAME ) ), + wp_kses_post( $link1 ), + wp_kses_post( $link2 ) + ); + + // Stop WP Core from outputting its version number and instead add both theirs & ours. + global $wp_version; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps + printf( + wp_kses_post( '

%1$s

' ), + sprintf( + // Translators: 1 - WP Core version number, 2 - Version number. + esc_html__( 'WordPress %1$s | Duplicate Post %2$s', 'duplicate-post-page-aioseo' ), + esc_html( $wp_version ), // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps + esc_html( AIOSEO_DUPLICATE_POST_VERSION ) + ) + ); + + remove_filter( 'update_footer', 'core_update_footer' ); + + return ''; + } +} \ No newline at end of file diff --git a/app/Admin/Notices/Review.php b/app/Admin/Notices/Review.php new file mode 100644 index 0000000..07ea5ba --- /dev/null +++ b/app/Admin/Notices/Review.php @@ -0,0 +1,145 @@ +admin->isDuplicatePostScreen() ) { + return; + } + + $dismissed = get_user_meta( get_current_user_id(), '_aioseo_duplicate_post_plugin_review_dismissed', true ); + if ( '3' === $dismissed || '4' === $dismissed ) { + return; + } + + if ( ! empty( $dismissed ) && $dismissed > time() ) { + return; + } + + // Show once plugin has been active for 2 weeks. + if ( ! aioseoDuplicatePost()->internalOptions->internal->firstActivated ) { + aioseoDuplicatePost()->internalOptions->internal->firstActivated = time(); + } + + $activated = aioseoDuplicatePost()->internalOptions->internal->firstActivated( time() ); + if ( $activated > strtotime( '-2 weeks' ) ) { + return; + } + + $this->showNotice(); + + // Print the script to the footer. + add_action( 'admin_footer', [ $this, 'printScript' ] ); + } + + /** + * Actually show the review plugin 2.0. + * + * @since 1.0.0 + * + * @return void + */ + public function showNotice() { + $string1 = sprintf( + // Translators: 1 - The plugin name ("Duplicate Post"). + __( 'Hey, we noticed you have been using %1$s for some time - that’s awesome! Could you please do us a BIG favor and give it a 5-star rating on WordPress to help us spread the word and boost our motivation?', 'duplicate-post-page-aioseo' ), // phpcs:ignore Generic.Files.LineLength.MaxExceeded + '' . esc_html( AIOSEO_DUPLICATE_POST_PLUGIN_NAME ) . '' + ); + + $allowedHtml = [ + 'strong' => [], + ]; + + ?> +
+
+

+

+ + + +  •  + + +  •  + + + +

+
+
+ core->assets->load( 'src/vue/standalone/review-notice/index.js', [], aioseoDuplicatePost()->helpers->getVueData() ); + } + + /** + * Dismiss the review plugin CTA. + * + * @since 1.0.0 + * + * @return void + */ + public function dismissNotice() { + // Early exit if we're not on a aioseo-duplicate-post-dismiss-review-plugin-cta action. + if ( ! isset( $_POST['action'] ) || 'aioseo-duplicate-post-dismiss-review-plugin-cta' !== $_POST['action'] ) { + return; + } + + check_ajax_referer( 'aioseo-duplicate-post-dismiss-review', 'nonce' ); + $delay = isset( $_POST['delay'] ) ? 'true' === sanitize_text_field( wp_unslash( $_POST['delay'] ) ) : false; // phpcs:ignore HM.Security.ValidatedSanitizedInput.InputNotSanitized + $relay = isset( $_POST['relay'] ) ? 'true' === sanitize_text_field( wp_unslash( $_POST['relay'] ) ) : false; // phpcs:ignore HM.Security.ValidatedSanitizedInput.InputNotSanitized + + if ( ! $delay ) { + update_user_meta( get_current_user_id(), '_aioseo_duplicate_post_plugin_review_dismissed', $relay ? '4' : '3' ); + + wp_send_json_success(); + + return; + } + + update_user_meta( get_current_user_id(), '_aioseo_duplicate_post_plugin_review_dismissed', strtotime( '+1 week' ) ); + + wp_send_json_success(); + } +} \ No newline at end of file diff --git a/app/Admin/Notifications.php b/app/Admin/Notifications.php new file mode 100644 index 0000000..a682101 --- /dev/null +++ b/app/Admin/Notifications.php @@ -0,0 +1,358 @@ +core->db->tableExists( 'aioseo_duplicate_post_notifications' ) ) { + aioseoDuplicatePost()->updates->addInitialTables(); + + return; + } + + add_action( 'init', [ $this, 'init' ], 2 ); + add_action( 'admin_notices', [ $this, 'renderNotices' ] ); + } + + /** + * Initialize notifications. + * + * @since 1.0.0 + * + * @return void + */ + public function init() { + $this->checkForUpdates(); + + $this->reviewNotice = new Notices\Review(); + } + + /** + * Renders the notices. + * + * @since 1.2.0 + * + * @return void + */ + public function renderNotices() { + if ( ! is_admin() ) { + return; + } + + // Ensure $this->reviewNotice is initialized + if ( isset( $this->reviewNotice ) ) { + $this->reviewNotice->maybeShowNotice(); + } + } + + /** + * Checks if we should update our notifications. + * + * @since 1.0.0 + * + * @return void + */ + private function checkForUpdates() { + $nextRun = aioseoDuplicatePost()->core->cache->get( 'admin_notifications_update' ); + if ( null !== $nextRun && time() < $nextRun ) { + return; + } + + aioseoDuplicatePost()->actionScheduler->scheduleAsync( 'aioseo_duplicate_post_admin_notifications_update' ); + aioseoDuplicatePost()->core->cache->update( 'admin_notifications_update', time() + DAY_IN_SECONDS ); + } + + /** + * Pulls in the notifications from our remote endpoint and stores them in the DB. + * + * @since 1.0.0 + * + * @return void + */ + public function update() { + $notifications = $this->fetch(); + if ( empty( $notifications ) ) { + return; + } + + foreach ( $notifications as $notification ) { + // First, let's check to see if the notification exists. If so, we want to override it. + $aioseoNotification = aioseoDuplicatePost()->core->db + ->start( 'aioseo_duplicate_post_notifications' ) + ->where( 'notification_id', $notification->id ) + ->run() + ->model( 'AIOSEO\\DuplicatePost\\Models\\Notification' ); + + $buttons = [ + 'button1' => [ + 'label' => ! empty( $notification->btns->main->text ) ? sanitize_text_field( $notification->btns->main->text ) : null, + 'url' => ! empty( $notification->btns->main->url ) ? esc_url_raw( $notification->btns->main->url ) : null + ], + 'button2' => [ + 'label' => ! empty( $notification->btns->alt->text ) ? sanitize_text_field( $notification->btns->alt->text ) : null, + 'url' => ! empty( $notification->btns->alt->url ) ? esc_url_raw( $notification->btns->alt->url ) : null + ] + ]; + + if ( ! $aioseoNotification->exists() ) { + $aioseoNotification = new Models\Notification(); + $aioseoNotification->slug = uniqid(); + $aioseoNotification->dismissed = 0; + } + + $aioseoNotification->notification_id = $notification->id; + $aioseoNotification->title = sanitize_text_field( $notification->title ); + $aioseoNotification->content = sanitize_text_field( $notification->content ); + $aioseoNotification->type = ! empty( $notification->notification_type ) ? sanitize_text_field( $notification->notification_type ) : 'info'; + $aioseoNotification->level = $notification->type; + $aioseoNotification->start = ! empty( $notification->start ) ? sanitize_text_field( $notification->start ) : null; + $aioseoNotification->end = ! empty( $notification->end ) ? sanitize_text_field( $notification->end ) : null; + $aioseoNotification->button1_label = $buttons['button1']['label']; + $aioseoNotification->button1_action = $buttons['button1']['url']; + $aioseoNotification->button2_label = $buttons['button2']['label']; + $aioseoNotification->button2_action = $buttons['button2']['url']; + + $aioseoNotification->save(); + + // Trigger the drawer to open. + aioseoDuplicatePost()->core->cache->update( 'show_notifications_drawer', true ); + } + } + + /** + * Pulls in the notifications from the remote feed. + * + * @since 1.0.0 + * + * @return array A list of notifications. + */ + private function fetch() { + $response = aioseoDuplicatePost()->helpers->wpRemoteGet( $this->getUrl() ); + if ( is_wp_error( $response ) ) { + return []; + } + + $body = wp_remote_retrieve_body( $response ); + if ( empty( $body ) ) { + return []; + } + + $notifications = json_decode( $body ); + if ( empty( $notifications ) ) { + return []; + } + + return $this->verify( $notifications ); + } + + /** + * Verifies a notification to see if it's valid before it is stored. + * + * @since 1.0.0 + * + * @param array $notifications List of notifications items to verify. + * @return array List of verified notifications. + */ + private function verify( $notifications ) { + if ( ! is_array( $notifications ) || empty( $notifications ) ) { + return []; + } + + $data = []; + foreach ( $notifications as $notification ) { + // The content and type should never be empty. If they are, ignore the notification. + if ( empty( $notification->content ) || empty( $notification->type ) ) { + continue; + } + + if ( ! is_array( $notification->type ) ) { + $notification->type = [ $notification->type ]; + } + + foreach ( $notification->type as $type ) { + $type = sanitize_text_field( $type ); + + // Ignore the notification if not a single type matches. + if ( ! $this->validateType( $type ) ) { + continue 2; + } + } + + // Ignore the notification if it already expired. + if ( ! empty( $notification->end ) && time() > strtotime( $notification->end ) ) { + continue; + } + + // Ignore the notification if it existed before installing Duplicate Post. + // Prevents spamming the user with notifications after activation. + $activated = aioseoDuplicatePost()->internalOptions->internal->firstActivated( time() ); + if ( ! empty( $notification->start ) && $activated > strtotime( $notification->start ) ) { + continue; + } + + $data[] = $notification; + } + + return $data; + } + + /** + * Validates the notification type. + * + * @since 1.0.0 + * + * @param string $type The notification type we are targeting. + * @return bool Whether the notification is valid. + */ + public function validateType( $type ) { + if ( 'all' === $type ) { + return true; + } + + // If we are targeting unlicensed users. + if ( 'free' === $type && ! aioseoDuplicatePost()->license->isActive() ) { + return true; + } + + // If we are targeting licensed users. + if ( 'licensed' === $type && aioseoDuplicatePost()->license->isActive() ) { + return true; + } + + // Store notice if version matches. + if ( $this->versionMatch( aioseoDuplicatePost()->version, $type ) ) { + return true; + } + + return false; + } + + /** + * Checks whether two versions are equal. + * + * @since 1.0.0 + * + * @param string $currentVersion The current version being used. + * @param string|array $compareVersion The version to compare with. + * @return bool Whether it is a match. + */ + private function versionMatch( $currentVersion, $compareVersion ) { + if ( is_array( $compareVersion ) ) { + foreach ( $compareVersion as $compareSingle ) { + $recursiveResult = $this->versionMatch( $currentVersion, $compareSingle ); + if ( $recursiveResult ) { + return true; + } + } + + return false; + } + + $currentParse = explode( '.', $currentVersion ); + if ( strpos( $compareVersion, '-' ) ) { + $compareParse = explode( '-', $compareVersion ); + } elseif ( strpos( $compareVersion, '.' ) ) { + $compareParse = explode( '.', $compareVersion ); + } else { + return false; + } + + $currentCount = count( $currentParse ); + $compareCount = count( $compareParse ); + for ( $i = 0; $i < $currentCount || $i < $compareCount; $i++ ) { + if ( isset( $compareParse[ $i ] ) && 'x' === strtolower( $compareParse[ $i ] ) ) { + unset( $compareParse[ $i ] ); + } + + if ( ! isset( $currentParse[ $i ] ) ) { + unset( $compareParse[ $i ] ); + } elseif ( ! isset( $compareParse[ $i ] ) ) { + unset( $currentParse[ $i ] ); + } + } + + foreach ( $compareParse as $index => $subNumber ) { + if ( $currentParse[ $index ] !== $subNumber ) { + return false; + } + } + + return true; + } + + + /** + * Returns the URL for the notifications endpoint. + * + * @since 1.0.0 + * + * @return string The URL. + */ + private function getUrl() { + if ( defined( 'AIOSEO_DUPLICATE_POST_NOTIFICATIONS_URL' ) ) { + return AIOSEO_DUPLICATE_POST_NOTIFICATIONS_URL; + } + + return $this->url; + } + + /** + * Extends a notice by a (default) 1 week start date. + * + * @since 1.0.0 + * + * @param string $notice The notice name. + * @param string $start How long to extend the notice. + * @return void + */ + public function remindMeLater( $notice, $start = '+1 week' ) { + $notification = Models\Notification::getNotificationByName( $notice ); + if ( ! $notification->exists() ) { + return; + } + + $notification->start = gmdate( 'Y-m-d H:i:s', strtotime( $start ) ); + $notification->save(); + } +} \ No newline at end of file diff --git a/app/Api/Api.php b/app/Api/Api.php new file mode 100644 index 0000000..c8af5bc --- /dev/null +++ b/app/Api/Api.php @@ -0,0 +1,211 @@ + [ + 'options' => [ 'callback' => [ 'VueSettings', 'getOptions' ], 'access' => 'everyone' ], + 'ping' => [ 'callback' => [ 'Ping', 'ping' ], 'access' => 'everyone' ], + 'get-timezone' => [ 'callback' => [ 'DuplicatePost', 'getTimezone' ], 'access' => 'everyone' ], + 'notices/check' => [ 'callback' => [ 'DuplicatePost', 'checkNotices' ], 'access' => 'everyone' ], + 'notices/check-revision' => [ 'callback' => [ 'DuplicatePost', 'checkRevision' ], 'access' => 'everyone' ], + 'get-original-post' => [ 'callback' => [ 'DuplicatePost', 'getOriginalPost' ], 'access' => 'everyone' ], + 'set-merge-ready' => [ 'callback' => [ 'DuplicatePost', 'setMergeReady' ], 'access' => 'everyone' ], + ], + 'POST' => [ + 'notices/dismiss' => [ 'callback' => [ 'DuplicatePost', 'dismissNotices' ], 'access' => 'everyone' ], + 'notifications/dismiss' => [ 'callback' => [ 'Notifications', 'dismissNotifications' ], 'access' => 'any' ], + 'options' => [ 'callback' => [ 'VueSettings', 'saveChanges' ], 'access' => 'aioseo_duplicate_post_settings' ], + 'plugins/deactivate' => [ 'callback' => [ 'Plugins', 'deactivatePlugins' ], 'access' => 'install_plugins' ], + 'plugins/install' => [ 'callback' => [ 'Plugins', 'installPlugins' ], 'access' => 'install_plugins' ], + 'settings/toggle-card' => [ 'callback' => [ 'VueSettings', 'toggleCard' ], 'access' => 'aioseo_duplicate_post_settings' ], + 'settings/toggle-radio' => [ 'callback' => [ 'VueSettings', 'toggleRadio' ], 'access' => 'aioseo_duplicate_post_settings' ], + 'settings/items-per-page' => [ 'callback' => [ 'VueSettings', 'changeItemsPerPage' ], 'access' => 'aioseo_duplicate_post_settings' ] + ], + 'DELETE' => [ + 'post' => [ 'callback' => [ 'Post', 'deletePost' ], 'access' => [ 'aioseo_duplicate_post_can_delete' ] ] + ] + // phpcs:enable WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound + ]; + + /** + * Class contructor. + * + * @since 1.0.0 + */ + public function __construct() { + add_filter( 'rest_allowed_cors_headers', [ $this, 'allowedHeaders' ] ); + add_action( 'rest_api_init', [ $this, 'registerRoutes' ] ); + } + + /** + * Registers the API routes. + * + * @since 1.0.0 + * + * @return void + */ + public function registerRoutes() { + $class = new \ReflectionClass( get_called_class() ); + foreach ( $this->routes as $method => $data ) { + foreach ( $data as $route => $options ) { + register_rest_route( + $this->namespace, + $route, + [ + 'methods' => $method, + 'permission_callback' => empty( $options['permissions'] ) ? [ $this, 'validRequest' ] : [ $this, $options['permissions'] ], + 'callback' => is_array( $options['callback'] ) + ? [ + ( + ! empty( $options['callback'][2] ) + ? $options['callback'][2] . '\\' . $options['callback'][0] + : ( + class_exists( $class->getNamespaceName() . '\\' . $options['callback'][0] ) + ? $class->getNamespaceName() . '\\' . $options['callback'][0] + : __NAMESPACE__ . '\\' . $options['callback'][0] + ) + ), + $options['callback'][1] + ] + : [ $this, $options['callback'] ] + ] + ); + } + } + } + + /** + * Sets headers that are allowed for our API routes. + * + * @since 1.0.0 + * + * @param array $allowHeaders The allowed request headers. + * @return array The allowed request headers. + */ + public function allowedHeaders( $allowHeaders ) { + if ( ! array_search( 'X-WP-Nonce', $allowHeaders, true ) ) { + $allowHeaders[] = 'X-WP-Nonce'; + } + + return $allowHeaders; + } + + /** + * Determine if the user is logged in and has the proper permissions. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request. + * @return bool Whether the user is allowed access to the route. + */ + public function validRequest( $request ) { + return is_user_logged_in() && $this->validateAccess( $request ); + } + + /** + * Validates access for the routes. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request. + * @return bool Whether the user is allowed access to the route. + */ + private function validateAccess( $request ) { + $routeData = $this->getRouteData( $request ); + if ( empty( $routeData ) || empty( $routeData['access'] ) ) { + return false; + } + + // Admins users always have access. + if ( aioseoDuplicatePost()->access->isAdmin() ) { + return true; + } + + switch ( $routeData['access'] ) { + case 'everyone': + // All users are able to access the route. + return true; + case 'any': + // Users with any Duplicate Post permission can access the route. + $user = wp_get_current_user(); + foreach ( $user->get_role_caps() as $capability => $enabled ) { + if ( $enabled && preg_match( '/^aioseo_duplicate_post_/', $capability ) ) { + return true; + } + } + + return false; + default: + // The user has access if he has any of the required capabilities. + if ( ! is_array( $routeData['access'] ) ) { + $routeData['access'] = [ $routeData['access'] ]; + } + + foreach ( $routeData['access'] as $access ) { + if ( current_user_can( $access ) ) { + return true; + } + } + + return false; + } + } + + /** + * Returns the data for the route that is being accessed. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request. + * @return array The route data. + */ + private function getRouteData( $request ) { + // NOTE: Since WordPress uses case-insensitive patterns to match routes, + // we are forcing everything to lower case to ensure we have the proper route. + // This prevents users with lower privileges from accessing routes they shouldn't. + $route = aioseoDuplicatePost()->helpers->toLowercase( $request->get_route() ); + $route = untrailingslashit( str_replace( '/' . $this->namespace . '/', '', $route ) ); + $routeData = isset( $this->routes[ $request->get_method() ][ $route ] ) ? $this->routes[ $request->get_method() ][ $route ] : []; + + // No direct route name, let's try the regexes. + if ( empty( $routeData ) ) { + foreach ( $this->routes[ $request->get_method() ] as $routeRegex => $routeInfo ) { + $routeRegex = str_replace( '@', '\@', $routeRegex ); + if ( preg_match( "@{$routeRegex}@", $route ) ) { + $routeData = $routeInfo; + break; + } + } + } + + return $routeData; + } +} \ No newline at end of file diff --git a/app/Api/DuplicatePost.php b/app/Api/DuplicatePost.php new file mode 100644 index 0000000..4b4b13b --- /dev/null +++ b/app/Api/DuplicatePost.php @@ -0,0 +1,220 @@ + true, + 'timezone' => $timezone, + 'gmtOffset' => $gmtOffset + ], 200 ); + } + + /** + * Save the status of dismissed notifications so they dont show anymore. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function dismissNotices( $request ) { + $postId = $request->get_param( 'post_id' ); + $urlParams = $request->get_param( 'url_params' ); + + if ( $postId ) { + + $postId = (int) untrailingslashit( wp_unslash( $postId ) ); + $notices = maybe_unserialize( get_option( 'aioseo_duplicate_post_notices' ) ); + + if ( isset( $urlParams['merged'] ) ) { + if ( empty( $notices ) ) { + $notices = []; + $notices[ $postId ] = [ 'merged' => 'dismissed' ]; + } else { + $notices[ $postId ]['merged'] = 'dismissed'; + } + } + + if ( isset( $urlParams['scheduled-merge'] ) ) { + if ( empty( $notices ) ) { + $notices = []; + $notices[ $postId ] = [ 'scheduled' => 'dismissed' ]; + } else { + $notices[ $postId ]['scheduled'] = 'dismissed'; + } + } + + if ( aioseoDuplicatePost()->main->hooks->functions->isRevision( $postId ) ) { + + if ( empty( $notices ) ) { + $notices = []; + $notices[ $postId ] = [ 'revision' => 'dismissed' ]; + } else { + $notices[ $postId ]['revision'] = 'dismissed'; + } + } + + update_option( 'aioseo_duplicate_post_notices', $notices ); + + return new \WP_REST_Response( [ + 'success' => true, + ], 200 ); + } else { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + } + + /** + * Check if the notices should be disblaed for the current post. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response The response. + */ + public static function checkNotices( $request ) { + + $postId = $request->get_param( 'post_id' ); + + if ( $postId ) { + + $postId = (int) untrailingslashit( wp_unslash( $postId ) ); + $notices = maybe_unserialize( get_option( 'aioseo_duplicate_post_notices' ) ); + + return new \WP_REST_Response( [ + 'success' => true, + 'notices' => $notices + ], 200 ); + } else { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + } + + /** + * Returns the settings. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response The response. + */ + public static function checkRevision( $request ) { + + $postId = $request->get_param( 'post_id' ); + + if ( $postId ) { + + $postId = (int) untrailingslashit( wp_unslash( $postId ) ); + + return new \WP_REST_Response( [ + 'success' => true, + 'message' => aioseoDuplicatePost()->main->hooks->classicEditor->displayNotices( $postId ) + ], 200 ); + } else { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + } + + /** + * Get the original post and additional metadata for a post. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response The response. + */ + public static function getOriginalPost( $request ) { + + $postId = $request->get_param( 'post_id' ); + + if ( $postId ) { + + $postId = (int) untrailingslashit( wp_unslash( $postId ) ); + + $originalPostId = get_post_meta( $postId, '_aioseo_original', true ); + $isRevision = get_post_meta( $postId, '_aioseo_revision', true ); + $mergeReady = get_post_meta( $postId, '_aioseo_merge_ready', true ); + + if ( $originalPostId ) { + $originalPost = get_post( $originalPostId ); + + return new \WP_REST_Response( [ + 'success' => true, + 'original_post' => $originalPost, + 'is_revision' => $isRevision, + 'merge_ready' => $mergeReady, + ], 200 ); + } else { + return new \WP_REST_Response( [ + 'success' => false + ], 200 ); + } + } else { + return new \WP_REST_Response( [ + 'success' => false + ], 200 ); + } + } + + /** + * Adds metadata for the post before merging. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response The response. + */ + public static function setMergeReady( $request ) { + + $postId = $request->get_param( 'post_id' ); + + if ( $postId ) { + + $postId = (int) untrailingslashit( wp_unslash( $postId ) ); + + // Add metadata so we know the post is ready for merging when clicking the merge button. + update_post_meta( $postId, '_aioseo_merge_ready', true ); + + return new \WP_REST_Response( [ + 'success' => true + ], 200 ); + } else { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + } +} \ No newline at end of file diff --git a/app/Api/Notifications.php b/app/Api/Notifications.php new file mode 100644 index 0000000..f15d4ce --- /dev/null +++ b/app/Api/Notifications.php @@ -0,0 +1,70 @@ +notifications->remindMeLater( $slug ); + + return new \WP_REST_Response( [ + 'success' => true, + 'notifications' => Models\Notification::getNotifications() + ], 200 ); + } + + /** + * Dismiss notifications. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function dismissNotifications( $request ) { + $slugs = $request->get_json_params(); + + $notifications = aioseoDuplicatePost()->core->db + ->start( 'aioseo_duplicate_post_notifications' ) + ->whereIn( 'slug', $slugs ) + ->run() + ->models( 'AIOSEO\\DuplicatePost\\Models\\Notification' ); + + foreach ( $notifications as $notification ) { + $notification->dismissed = 1; + $notification->save(); + } + + // Dismiss static notifications. + if ( in_array( 'notification-review', $slugs, true ) ) { + update_user_meta( get_current_user_id(), '_aioseo_duplicate_post_notification_plugin_review_dismissed', '3' ); + } + + if ( in_array( 'notification-review-delay', $slugs, true ) ) { + update_user_meta( get_current_user_id(), '_aioseo_duplicate_post_notification_plugin_review_dismissed', strtotime( '+1 week' ) ); + } + + return new \WP_REST_Response( [ + 'success' => true, + 'notifications' => Models\Notification::getNotifications() + ], 200 ); + } +} \ No newline at end of file diff --git a/app/Api/Ping.php b/app/Api/Ping.php new file mode 100644 index 0000000..680d97d --- /dev/null +++ b/app/Api/Ping.php @@ -0,0 +1,27 @@ + true + ], 200 ); + } +} \ No newline at end of file diff --git a/app/Api/Plugins.php b/app/Api/Plugins.php new file mode 100644 index 0000000..8cd195b --- /dev/null +++ b/app/Api/Plugins.php @@ -0,0 +1,124 @@ +get_json_params(); + $plugins = ! empty( $body['plugins'] ) ? $body['plugins'] : []; + $network = ! empty( $body['network'] ) ? $body['network'] : false; + $error = esc_html__( 'Installation failed. Please check permissions and try again.', 'duplicate-post-page-aioseo' ); + + if ( ! is_array( $plugins ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => $error + ], 400 ); + } + + if ( ! aioseoDuplicatePost()->helpers->canInstall() ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => $error + ], 400 ); + } + + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $failed = []; + $completed = []; + foreach ( $plugins as $plugin ) { + if ( empty( $plugin['plugin'] ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => $error + ], 400 ); + } + + $result = aioseoDuplicatePost()->helpers->installAddon( $plugin['plugin'], $network ); + if ( ! $result ) { + $failed[] = $plugin['plugin']; + } else { + $completed[ $plugin['plugin'] ] = $result; + } + } + + return new \WP_REST_Response( [ + 'success' => true, + 'completed' => $completed, + 'failed' => $failed + ], 200 ); + } + + /** + * Deactivates plugins. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function deactivatePlugins( $request ) { + $body = $request->get_json_params(); + $plugins = ! empty( $body['plugins'] ) ? $body['plugins'] : []; + $network = ! empty( $body['network'] ) ? $body['network'] : false; + $error = esc_html__( 'Deactivation failed. Please check permissions and try again.', 'duplicate-post-page-aioseo' ); + + if ( ! is_array( $plugins ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => $error + ], 400 ); + } + + if ( ! current_user_can( 'install_plugins' ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => $error + ], 400 ); + } + + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $failed = []; + $completed = []; + foreach ( $plugins as $plugin ) { + if ( empty( $plugin['plugin'] ) ) { + return new \WP_REST_Response( [ + 'success' => false, + 'message' => $error + ], 400 ); + } + + $deactivated = deactivate_plugins( $plugin['plugin'], false, $network ); + if ( is_wp_error( $deactivated ) ) { + $failed[] = $plugin['plugin']; + } + + $completed[] = $plugin['plugin']; + } + + return new \WP_REST_Response( [ + 'success' => true, + 'completed' => $completed, + 'failed' => $failed + ], 200 ); + } +} \ No newline at end of file diff --git a/app/Api/VueSettings.php b/app/Api/VueSettings.php new file mode 100644 index 0000000..d3eb733 --- /dev/null +++ b/app/Api/VueSettings.php @@ -0,0 +1,138 @@ + true, + 'options' => aioseoDuplicatePost()->options->all(), + 'internalOptions' => aioseoDuplicatePost()->internalOptions->all(), + 'settings' => aioseoDuplicatePost()->vueSettings->all() + ], 200 ); + } + + /** + * Toggles a card in the settings. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function toggleCard( $request ) { + $body = $request->get_json_params(); + $card = ! empty( $body['card'] ) ? sanitize_text_field( $body['card'] ) : null; + + $cards = aioseoDuplicatePost()->vueSettings->toggledCards; + if ( ! array_key_exists( $card, $cards ) ) { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + + $cards[ $card ] = ! $cards[ $card ]; + aioseoDuplicatePost()->vueSettings->toggledCards = $cards; + + return new \WP_REST_Response( [ + 'success' => true + ], 200 ); + } + + /** + * Toggles a radio in the settings. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function toggleRadio( $request ) { + $body = $request->get_json_params(); + $radio = ! empty( $body['radio'] ) ? sanitize_text_field( $body['radio'] ) : null; + $value = ! empty( $body['value'] ) ? sanitize_text_field( $body['value'] ) : null; + + $radios = aioseoDuplicatePost()->vueSettings->toggledRadio; + if ( ! array_key_exists( $radio, $radios ) ) { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + + $radios[ $radio ] = $value; + aioseoDuplicatePost()->vueSettings->toggledRadio = $radios; + + return new \WP_REST_Response( [ + 'success' => true + ], 200 ); + } + + /** + * Toggles a table's items per page setting. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function changeItemsPerPage( $request ) { + $body = $request->get_json_params(); + $table = ! empty( $body['table'] ) ? sanitize_text_field( $body['table'] ) : null; + $value = ! empty( $body['value'] ) ? intval( $body['value'] ) : null; + + $tables = aioseoDuplicatePost()->vueSettings->tablePagination; + if ( ! array_key_exists( $table, $tables ) ) { + return new \WP_REST_Response( [ + 'success' => false + ], 400 ); + } + + $tables[ $table ] = $value; + aioseoDuplicatePost()->vueSettings->tablePagination = $tables; + + return new \WP_REST_Response( [ + 'success' => true + ], 200 ); + } + + /** + * Save options from the frontend. + * + * @since 1.0.0 + * + * @param \WP_REST_Request $request The REST Request + * @return \WP_REST_Response The response. + */ + public static function saveChanges( $request ) { + $body = $request->get_json_params(); + $options = ! empty( $body['options'] ) ? $body['options'] : []; // The options class will sanitize them. + + aioseoDuplicatePost()->options->sanitizeAndSave( $options ); + + // Re-initialize the notices. + aioseoDuplicatePost()->notifications->init(); + + return new \WP_REST_Response( [ + 'success' => true, + 'notifications' => Models\Notification::getNotifications() + ], 200 ); + } +} \ No newline at end of file diff --git a/app/Core/Assets.php b/app/Core/Assets.php new file mode 100644 index 0000000..6f463eb --- /dev/null +++ b/app/Core/Assets.php @@ -0,0 +1,655 @@ +core = $core; + $this->version = aioseoDuplicatePost()->version; + $this->manifestFile = AIOSEO_DUPLICATE_POST_DIR . '/dist/manifest.php'; + $this->isDev = aioseoDuplicatePost()->isDev; + + if ( $this->isDev ) { + $this->domain = getenv( 'VITE_AIOSEO_DUPLICATE_POST_DOMAIN' ); + $this->port = getenv( 'VITE_AIOSEO_DUPLICATE_POST_DEV_PORT' ); + } + + add_filter( 'script_loader_tag', [ $this, 'scriptLoaderTag' ], 10, 3 ); + } + + /** + * The asset to load. + * + * @since 1.0.0 + * + * @param string $asset The asset to load. + * @param array $dependencies An array of dependencies. + * @param mixed $data Any data to be localized. + * @param string $objectName The object name to use when localizing. + * @return void + */ + public function load( $asset, $dependencies = [], $data = null, $objectName = 'aioseoDuplicatePost' ) { + $this->jsPreloadImports( $asset ); + $this->loadCss( $asset ); + $this->enqueueJs( $asset, $dependencies, $data, $objectName ); + } + + /** + * Filter the script loader tag if this is our script. + * + * @since 1.0.0 + * + * @param string $tag The tag that is going to be output. + * @param string $handle The handle for the script. + * @return string The modified tag. + */ + public function scriptLoaderTag( $tag, $handle, $src ) { + if ( $this->skipModuleTag( $handle ) ) { + return $tag; + } + + $tag = str_replace( $src, $this->normalizeAssetsHost( $src ), $tag ); + + // Remove the type and re-add it as module. + $tag = preg_replace( '/type=[\'"].*?[\'"]/', '', $tag ); + $tag = preg_replace( '/ + + \ No newline at end of file diff --git a/src/vue/components/core/Header.vue b/src/vue/components/core/Header.vue new file mode 100644 index 0000000..e986799 --- /dev/null +++ b/src/vue/components/core/Header.vue @@ -0,0 +1,219 @@ + + + + + \ No newline at end of file diff --git a/src/vue/components/core/MultiSelectOptions.vue b/src/vue/components/core/MultiSelectOptions.vue new file mode 100644 index 0000000..53d9e8c --- /dev/null +++ b/src/vue/components/core/MultiSelectOptions.vue @@ -0,0 +1,136 @@ + + + + + \ No newline at end of file diff --git a/src/vue/components/core/main/Index.vue b/src/vue/components/core/main/Index.vue new file mode 100644 index 0000000..64fae31 --- /dev/null +++ b/src/vue/components/core/main/Index.vue @@ -0,0 +1,148 @@ + + + \ No newline at end of file diff --git a/src/vue/components/core/main/Tabs.vue b/src/vue/components/core/main/Tabs.vue new file mode 100644 index 0000000..861a1c8 --- /dev/null +++ b/src/vue/components/core/main/Tabs.vue @@ -0,0 +1,485 @@ + + + + + \ No newline at end of file diff --git a/src/vue/components/index.js b/src/vue/components/index.js new file mode 100644 index 0000000..b39b409 --- /dev/null +++ b/src/vue/components/index.js @@ -0,0 +1,2 @@ +// Autoload some of our basic components. +import '#/vue/components/base/index.js' \ No newline at end of file diff --git a/src/vue/components/svg/Logo.vue b/src/vue/components/svg/Logo.vue new file mode 100644 index 0000000..57e77ae --- /dev/null +++ b/src/vue/components/svg/Logo.vue @@ -0,0 +1,189 @@ + \ No newline at end of file diff --git a/src/vue/components/table/Column.vue b/src/vue/components/table/Column.vue new file mode 100644 index 0000000..6bd1669 --- /dev/null +++ b/src/vue/components/table/Column.vue @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/src/vue/components/table/Row.vue b/src/vue/components/table/Row.vue new file mode 100644 index 0000000..f83dfe1 --- /dev/null +++ b/src/vue/components/table/Row.vue @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/src/vue/composables/SaveChanges.js b/src/vue/composables/SaveChanges.js new file mode 100644 index 0000000..911b668 --- /dev/null +++ b/src/vue/composables/SaveChanges.js @@ -0,0 +1,43 @@ +import { + useOptionsStore, + useRootStore +} from '#/vue/stores' + +export function useSaveChanges () { + const processSaveChanges = () => { + const rootStore = useRootStore() + rootStore.loading = true + + let switchBack = false, + saved = false + + const action = 'saveChanges' + + setTimeout(() => { + switchBack = true + if (saved) { + rootStore.loading = false + } + }, 1500) + + const optionsStore = useOptionsStore() + optionsStore[action]() + .then(response => { + if (response && response.body.redirection) { + return + } + + if (switchBack) { + rootStore.loading = false + } else { + saved = true + } + + window.aioseoDuplicatePostBus.$emit('changes-saved') + }) + } + + return { + processSaveChanges + } +} \ No newline at end of file diff --git a/src/vue/composables/WpTable.js b/src/vue/composables/WpTable.js new file mode 100644 index 0000000..66fd93d --- /dev/null +++ b/src/vue/composables/WpTable.js @@ -0,0 +1,143 @@ +import { ref, computed, onMounted } from 'vue' + +import { + useSettingsStore +} from '#/vue/stores' + +import { useScrollTo } from '@/vue/composables/ScrollTo' + +export function useWpTable ({ + changeItemsPerPageSlug, + fetchData, + slug, + tableId +}) { + const filter = ref('all') + const orderBy = ref(null) + const orderDir = ref('asc') + const pageNumber = ref(1) + const resultsPerPage = ref(20) + const searchTerm = ref(null) + const selectedFilters = ref(null) + const wpTableKey = ref(0) + const wpTableLoading = ref(false) + const settingsStore = useSettingsStore() + const scrollTo = useScrollTo().scrollTo + + const refreshTable = () => { + wpTableLoading.value = true + + return processFetchTableData() + .then(() => { + wpTableLoading.value = false + }) + } + + const processAdditionalFilters = ({ filters }) => { + wpTableLoading.value = true + + processFetchTableData(filters) + .then(() => (wpTableLoading.value = false)) + } + + const processSearch = (term) => { + pageNumber.value = 1 + searchTerm.value = term + wpTableLoading.value = true + + processFetchTableData() + .then(() => (wpTableLoading.value = false)) + } + + const processPagination = (number) => { + pageNumber.value = number + wpTableLoading.value = true + + processFetchTableData() + .then(() => (wpTableLoading.value = false)) + } + + const processFilterTable = (tableFilter) => { + filter.value = tableFilter.slug + searchTerm.value = null + pageNumber.value = 1 + wpTableLoading.value = true + + resetSelectedFilters() + + processFetchTableData() + .then(() => (wpTableLoading.value = false)) + } + + const processChangeItemsPerPage = (newNumber) => { + pageNumber.value = 1 + resultsPerPage.value = newNumber + wpTableLoading.value = true + + settingsStore.changeItemsPerPage({ + slug : changeItemsPerPageSlug, + value : newNumber + }) + .then(() => processFetchTableData() + .then(() => scrollTo(tableId)) + ) + .then(() => (wpTableLoading.value = false)) + } + + const processSort = (column, event) => { + event.target.blur() + orderBy.value = column.slug + orderDir.value = orderBy.value !== column.slug ? column.sortDir : ('asc' === column.sortDir ? 'desc' : 'asc') + wpTableLoading.value = true + + processFetchTableData() + .then(() => (wpTableLoading.value = false)) + } + + const offset = computed(() => { + return 1 === pageNumber.value ? 0 : (pageNumber.value - 1) * resultsPerPage.value + }) + + const processFetchTableData = (additionalFilters = selectedFilters.value) => { + return fetchData({ + slug, + orderBy : orderBy.value, + orderDir : orderDir.value, + limit : resultsPerPage.value, + offset : offset.value, + searchTerm : searchTerm.value, + filter : filter.value, + additionalFilters + }) + } + + // TODO: Probably eliminate this altogether. + const resetSelectedFilters = () => { + // Implementation of resetting selected filters + } + + onMounted(() => { + resultsPerPage.value = settingsStore.settings.tablePagination[changeItemsPerPageSlug] || resultsPerPage.value + }) + + return { + filter, + orderBy, + orderDir, + pageNumber, + processAdditionalFilters, + processChangeItemsPerPage, + processFetchTableData, + processFilterTable, + processPagination, + processSearch, + processSort, + refreshTable, + resetSelectedFilters, + resultsPerPage, + searchTerm, + selectedFilters, + wpTableKey, + wpTableLoading + } +} \ No newline at end of file diff --git a/src/vue/pages/about/App.vue b/src/vue/pages/about/App.vue new file mode 100644 index 0000000..2ffda75 --- /dev/null +++ b/src/vue/pages/about/App.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/vue/pages/about/main.js b/src/vue/pages/about/main.js new file mode 100644 index 0000000..9c656b8 --- /dev/null +++ b/src/vue/pages/about/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' + +import loadPlugins from '#/vue/plugins' +import { loadPiniaStores } from '#/vue/stores' + +import App from './App.vue' +import startRouter from '#/vue/router' +import paths from './router/paths' + +let app = createApp(App) +app = loadPlugins(app) + +const router = startRouter(paths, app) +router.app = app +app.use(router) + +loadPiniaStores(app, router) + +// // Set state from the window object. +app.mount('#aioseo-duplicate-post-app') + +export default app \ No newline at end of file diff --git a/src/vue/pages/about/router/paths.js b/src/vue/pages/about/router/paths.js new file mode 100644 index 0000000..c54dd03 --- /dev/null +++ b/src/vue/pages/about/router/paths.js @@ -0,0 +1,22 @@ +import { __ } from '@wordpress/i18n' + +const td = import.meta.env.VITE_TEXTDOMAIN +const loadView = view => { + return () => import(`../views/${view}.vue`) +} + +export default [ + { + path : '/:pathMatch(.*)*', + redirect : '/about-us' + }, + { + path : '/about-us', + name : 'about-us', + component : loadView('Main'), + meta : { + access : 'aioseo_duplicate_post_about_us_page', + name : __('About Us', td) + } + } +] \ No newline at end of file diff --git a/src/vue/pages/about/views/AboutUs.vue b/src/vue/pages/about/views/AboutUs.vue new file mode 100644 index 0000000..be41ae1 --- /dev/null +++ b/src/vue/pages/about/views/AboutUs.vue @@ -0,0 +1,743 @@ + + + + + \ No newline at end of file diff --git a/src/vue/pages/about/views/Main.vue b/src/vue/pages/about/views/Main.vue new file mode 100644 index 0000000..3238ead --- /dev/null +++ b/src/vue/pages/about/views/Main.vue @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/src/vue/pages/duplicator/App.vue b/src/vue/pages/duplicator/App.vue new file mode 100644 index 0000000..2ffda75 --- /dev/null +++ b/src/vue/pages/duplicator/App.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/vue/pages/duplicator/main.js b/src/vue/pages/duplicator/main.js new file mode 100644 index 0000000..9c656b8 --- /dev/null +++ b/src/vue/pages/duplicator/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' + +import loadPlugins from '#/vue/plugins' +import { loadPiniaStores } from '#/vue/stores' + +import App from './App.vue' +import startRouter from '#/vue/router' +import paths from './router/paths' + +let app = createApp(App) +app = loadPlugins(app) + +const router = startRouter(paths, app) +router.app = app +app.use(router) + +loadPiniaStores(app, router) + +// // Set state from the window object. +app.mount('#aioseo-duplicate-post-app') + +export default app \ No newline at end of file diff --git a/src/vue/pages/duplicator/router/paths.js b/src/vue/pages/duplicator/router/paths.js new file mode 100644 index 0000000..2b1e384 --- /dev/null +++ b/src/vue/pages/duplicator/router/paths.js @@ -0,0 +1,23 @@ +import { __ } from '@wordpress/i18n' + +const td = import.meta.env.VITE_TEXTDOMAIN +const loadView = view => { + return () => import(`../views/${view}.vue`) +} + +export default [ + { + path : '/:pathMatch(.*)*', + redirect : '/settings' + }, + { + path : '/settings', + name : 'settings', + component : loadView('Main'), + meta : { + access : 'aioseo_duplicate_post_settings', + name : __('Settings', td), + hideSaveButton : false + } + } +] \ No newline at end of file diff --git a/src/vue/pages/duplicator/views/Main.vue b/src/vue/pages/duplicator/views/Main.vue new file mode 100644 index 0000000..5189812 --- /dev/null +++ b/src/vue/pages/duplicator/views/Main.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/src/vue/pages/duplicator/views/Settings.vue b/src/vue/pages/duplicator/views/Settings.vue new file mode 100644 index 0000000..e2c1604 --- /dev/null +++ b/src/vue/pages/duplicator/views/Settings.vue @@ -0,0 +1,367 @@ + + + + + \ No newline at end of file diff --git a/src/vue/plugins/constants.js b/src/vue/plugins/constants.js new file mode 100644 index 0000000..bc17cea --- /dev/null +++ b/src/vue/plugins/constants.js @@ -0,0 +1,17 @@ +import { __ } from '@wordpress/i18n' + +const td = import.meta.env.VITE_TEXTDOMAIN + +export const GLOBAL_STRINGS = { + no : __('No', td), + yes : __('Yes', td), + off : __('Off', td), + on : __('On', td), + show : __('Show', td), + hide : __('Hide', td), + learnMore : __('Learn More', td), + disabled : __('Disabled', td), + enabled : __('Enabled', td), + include : __('Include', td), + remove : __('Remove', td) +} \ No newline at end of file diff --git a/src/vue/plugins/index.js b/src/vue/plugins/index.js new file mode 100644 index 0000000..6db7bc9 --- /dev/null +++ b/src/vue/plugins/index.js @@ -0,0 +1,52 @@ +import emitter from 'tiny-emitter/instance' +import VueScrollTo from 'vue-scrollto' + +import * as constants from './constants' +import translate from './translations' +import links from '#/vue/utils/links' + +window.aioseoDuplicatePost = window.aioseoDuplicatePost || {} +window.aioseoDuplicatePostBus = window.aioseoDuplicatePostBus || { + $on : (...args) => emitter.on(...args), + $once : (...args) => emitter.once(...args), + $off : (...args) => emitter.off(...args), + $emit : (...args) => emitter.emit(...args) +} + +if (import.meta.env.PROD) { + window.__aioseoDynamicImportPreload__ = filename => { + return `${window.aioseoDuplicatePost.urls.publicPath || '/'}dist/assets/${filename}` + } +} + +export default app => { + app.use(VueScrollTo, { + container : 'body', + duration : 1000, + easing : 'ease-in-out', + offset : 0, + force : true, + cancelable : true, + onStart : false, + onDone : false, + onCancel : false, + x : false, + y : true + }) + + // TODO: Remove all the lines below once the main plugin no longer has these set as global props. + // We can't remove them here until then since we import files from the main plugin that use them. + app.provide('$constants', constants) + app.provide('$td', import.meta.env.VITE_TEXTDOMAIN) + app.provide('$links', links) + app.provide('$t', translate) + + app.config.globalProperties.$constants = constants + app.config.globalProperties.$td = import.meta.env.VITE_TEXTDOMAIN + app.config.globalProperties.$tdPro = import.meta.env.VITE_TEXTDOMAIN_PRO + + app.$links = app.config.globalProperties.$links = links + app.$t = app.config.globalProperties.$t = translate + + return app +} \ No newline at end of file diff --git a/src/vue/plugins/translations.js b/src/vue/plugins/translations.js new file mode 100644 index 0000000..794efe7 --- /dev/null +++ b/src/vue/plugins/translations.js @@ -0,0 +1,8 @@ +import * as translate from '@wordpress/i18n' + +if (window.aioseoDuplicatePost.translations) { + translate.setLocaleData(window.aioseoDuplicatePost.translations.translations, import.meta.env.VITE_TEXTDOMAIN) +} else { + console.warn('Translations couldn\'t be loaded.') +} +export default translate \ No newline at end of file diff --git a/src/vue/router/index.js b/src/vue/router/index.js new file mode 100644 index 0000000..0975a84 --- /dev/null +++ b/src/vue/router/index.js @@ -0,0 +1,37 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +import { + loadPiniaStores, + useRootStore +} from '#/vue/stores' + +export default (paths, app) => { + const router = createRouter({ + history : createWebHashHistory(`wp-admin/admin.php?page=aioseo-duplicate-post-${window.aioseoDuplicatePost.page}`), + routes : paths, + scrollBehavior (to, from, savedPosition) { + if (savedPosition) { + return savedPosition + } + if (to.hash) { + return { selector: to.hash } + } + return { left: 0, top: 0 } + } + }) + + router.beforeEach(async (to, from, next) => { + const rootStore = useRootStore() + if (!rootStore.loaded) { + loadPiniaStores(app) + } + + // TODO: Add this in later. + // Make sure the API is available. + // rootStore.ping() + + return next() + }) + + return router +} \ No newline at end of file diff --git a/src/vue/standalone/duplicate-post/assets/components/handleMergeAndCompare.jsx b/src/vue/standalone/duplicate-post/assets/components/handleMergeAndCompare.jsx new file mode 100644 index 0000000..2b892df --- /dev/null +++ b/src/vue/standalone/duplicate-post/assets/components/handleMergeAndCompare.jsx @@ -0,0 +1,169 @@ +import http from '#/vue/utils/http' +import links from '#/vue/utils/links' + +const { dispatch, subscribe, select } = window.wp.data +const { Button } = window.wp.components +const { __, setLocaleData } = window.wp.i18n +const { createInterpolateElement } = window.wp.element +const td = import.meta.env.VITE_TEXTDOMAIN + +/** + * Fires when the save and compare button is clicked. + * + * @param {number} originalPostId - The id of the original Post. + * @returns {void} + */ +const saveAndCompare = () => { + return async () => { + try { + await dispatch('core/editor').savePost() + if (window.aioseoDuplicatePost.compareLink) { + window.location.href = window.aioseoDuplicatePost.compareLink + } + } catch (error) { + console.error(__('An error occurred while saving the post.', td)) + } + } +} + +/** + * Translate the strings and create buttons for merging and comparing. + * Additionally add custom functionality to the publish button. + * + * @param {number} originalPostId - The id of the original Post. + * @returns {void} + */ +export default function translateAndMerge (originalPostId) { + const mergeStrings = { + Publish : __('Merge', td), + 'Publish:' : __('Merge:', td), + 'Publish on:' : __('Merge on:', td), + + 'Are you ready to publish?' : __('Are you ready to merge your post?', td), + 'Double-check your settings before publishing.' : + createInterpolateElement( + __('After merging, your changes will be placed into the original post and you\'ll be redirected there.

Do you want to compare your changes with the original version before merging?

', + td), + { + button : ', + td), + { + button :