+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ strings.saveChanges }}
+
+
+
+
+
+
+
+
\ 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/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 : ,
+ br :
+ }
+ ),
+
+ Schedule : __('Schedule merge', td),
+ 'Schedule…' : __('Schedule merge...', td),
+ 'post action/button label\u0004Schedule' : __('Schedule merge', td),
+
+ 'Are you ready to schedule?' : __('Are you ready to schedule the merging of your post?', td),
+ 'Your work will be published at the specified date and time.' :
+ createInterpolateElement(
+ __('You\'re about to replace the original with this rewritten post at the specified date and time.
Do you want to compare your changes with the original version before merging?
',
+ td),
+ {
+ button : ,
+ br :
+ }
+ ),
+ 'is now scheduled. It will go live on' :
+ __(', the rewritten post, is now scheduled to replace the original post. It will be published on',
+ td)
+ }
+
+ for (const original in mergeStrings) {
+ setLocaleData({
+ [original] : [
+ mergeStrings[original],
+ td
+ ]
+ })
+ }
+
+ // Check the Publish button. If the strings are incorrect, change them.
+ const publishButton = document.querySelector('.editor-post-publish-button__button') || document.querySelector('.editor-post-publish-button')
+ if (publishButton) {
+ if ('Publish' === publishButton?.innerText) {
+ publishButton.innerText = __('Merge', td)
+ }
+
+ if ('Schedule' === publishButton?.innerText) {
+ publishButton.innerText = __('Schedule merge', td)
+ }
+ }
+
+ let isEventListenerAdded = false
+
+ const getCurrentTimeZoneOffsetInHours = () => {
+ const offset = new Date().getTimezoneOffset()
+ return offset / 60
+ }
+
+ const handleClick = async (event) => {
+ event.preventDefault() // Prevent the default publish action
+ event.stopPropagation()
+
+ const postId = window.aioseoDuplicatePost.postId
+ try {
+ const wpTimezone = await http.get(links.restUrl('get-timezone'))
+ const wpOffset = wpTimezone.body.gmtOffset
+ const myOffset = getCurrentTimeZoneOffsetInHours()
+ const now = new Date()
+ const postDate = new Date(select('core/editor').getEditedPostAttribute('date'))
+
+ // Adjust dates to UTC
+ const nowUTC = new Date(now.getTime() + myOffset * 60 * 60 * 1000)
+ const postDateUTC = new Date(postDate.getTime() - wpOffset * 60 * 60 * 1000)
+
+ const isScheduled = postDateUTC > nowUTC
+
+ if (isScheduled) {
+ await dispatch('core/editor').editPost({ status: 'future' })
+ } else {
+ await http.get(links.restUrl(`set-merge-ready?post_id=${postId}`))
+ }
+ await dispatch('core/editor').savePost()
+
+ // Redirect after a successful merge
+ if (0 !== originalPostId) {
+ if (!isScheduled) {
+ window.location.href = `${window.location.origin}/wp-admin/post.php?post=${originalPostId}&action=edit&merged=1`
+ }
+ }
+ } catch (error) {
+ console.error('Error during merge and save:', error)
+ }
+ }
+
+ // Function to add the event listener to the publish button.
+ const addEventListenerToPublishButton = () => {
+ const publishButton = document.querySelector('.editor-post-publish-button')
+ if (publishButton && !isEventListenerAdded) {
+ publishButton.addEventListener('click', handleClick)
+ isEventListenerAdded = true
+ }
+ }
+
+ // Create a MutationObserver to watch for changes in the DOM.
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if ('childList' === mutation.type) {
+ addEventListenerToPublishButton()
+ }
+ })
+ })
+
+ // Start observing the body for changes in the DOM
+ observer.observe(document.body, {
+ childList : true,
+ subtree : true
+ })
+
+ // Override Merge/Publish button behavior
+ subscribe(() => {
+ const isPublishPanelOpened = select('core/edit-post').isPublishSidebarOpened()
+ if (isPublishPanelOpened) {
+ addEventListenerToPublishButton()
+ } else {
+ isEventListenerAdded = false // Reset the flag when the sidebar is closed
+ }
+ })
+}
\ No newline at end of file
diff --git a/src/vue/standalone/duplicate-post/assets/js/mergeAndCompare.js b/src/vue/standalone/duplicate-post/assets/js/mergeAndCompare.js
new file mode 100644
index 0000000..0bdb027
--- /dev/null
+++ b/src/vue/standalone/duplicate-post/assets/js/mergeAndCompare.js
@@ -0,0 +1,34 @@
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+import translateAndMerge from '../components/handleMergeAndCompare'
+
+/**
+ * Checks if the current post is a revision and has an original post.
+ * Adds the merge and compare strings and buttons.
+ * Handles compare and merge functionality.
+ *
+ * @returns {void} Returns nothing.
+ */
+export default async function mergeAndCompare () {
+ const postId = window.aioseoDuplicatePost.postId
+
+ // Translate strings only if it has an original post and it is a revision
+ let originalPostId = 0,
+ isRevision = false
+ try {
+ const response = await http.get(links.restUrl(`get-original-post?post_id=${postId}`))
+ if (response.body.success) {
+ originalPostId = response.body.original_post.ID
+ isRevision = response.body.is_revision
+ } else {
+ originalPostId = 0
+ }
+ } catch (error) {
+ originalPostId = 0
+ }
+
+ if (0 !== originalPostId && isRevision) {
+ translateAndMerge(originalPostId)
+ }
+}
\ No newline at end of file
diff --git a/src/vue/standalone/duplicate-post/assets/js/notices.js b/src/vue/standalone/duplicate-post/assets/js/notices.js
new file mode 100644
index 0000000..6bd19a3
--- /dev/null
+++ b/src/vue/standalone/duplicate-post/assets/js/notices.js
@@ -0,0 +1,101 @@
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+import { __ } from '@wordpress/i18n'
+
+const { dispatch, subscribe } = window.wp.data
+
+let isEventListenerAdded = false,
+ observer
+
+async function dismissNotices () {
+ subscribe(() => {
+ if (isEventListenerAdded) return
+
+ if (!observer) {
+ // Create a MutationObserver to watch for changes in the DOM
+ observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if ('childList' === mutation.type) {
+ const dismissBtn = document.querySelector('.components-notice .components-button')
+ if (dismissBtn && 'Close' === dismissBtn.getAttribute('aria-label')) {
+ const urlParams = new URLSearchParams(window.location.search)
+ const paramsObject = {}
+
+ for (const [ key, value ] of urlParams.entries()) {
+ paramsObject[key] = value
+ }
+
+ dismissBtn.addEventListener('click', async () => {
+ await http.post(links.restUrl('notices/dismiss'))
+ .send({
+ post_id : window.aioseoDuplicatePost.postId,
+ url_params : paramsObject
+ })
+ })
+ isEventListenerAdded = true
+ observer.disconnect()
+ }
+ }
+ })
+ })
+
+ // Start observing the body for changes in the DOM
+ observer.observe(document.body, {
+ childList : true,
+ subtree : true
+ })
+ }
+ })
+}
+
+dismissNotices()
+
+export async function showNotices () {
+ const td = import.meta.env.VITE_TEXTDOMAIN
+ const postId = window.aioseoDuplicatePost.postId
+ let notices = null
+
+ const responseNotices = await http.get(links.restUrl(`notices/check?post_id=${postId}`))
+ if (responseNotices.body.success) {
+ notices = responseNotices.body.notices
+ }
+
+ const revisionMessage = await http.get(links.restUrl(`notices/check-revision?post_id=${postId}`))
+ if (revisionMessage.body.success) {
+ const message = revisionMessage.body.message
+
+ if (message && (!notices || !notices[postId] || (notices[postId] && 'dismissed' !== notices[postId].revision))) {
+ dispatch('core/notices').createNotice(
+ 'warning',
+ message,
+ {
+ id : 'revision_notice',
+ isDismissible : true
+ }
+ )
+ }
+ }
+
+ const urlParams = new URLSearchParams(window.location.search)
+ if ('1' === urlParams.get('merged') && (!notices || (notices[postId] && 'dismissed' !== notices[postId].merged))) {
+ dispatch('core/notices').createNotice(
+ 'success',
+ __('😎 Looking good! Your revision has been updated to the original post and you are now all set to publish.', td),
+ {
+ id : 'merge_success_notice',
+ isDismissible : true
+ }
+ )
+ }
+
+ if ('1' === urlParams.get('scheduled-merge') && (!notices || (notices[postId] && 'dismissed' !== notices[postId].scheduled))) {
+ dispatch('core/notices').createNotice(
+ 'success',
+ __('😎 Looking good! Your revision has been scheduled to merge with the original post.', td),
+ {
+ id : 'scheduled_success_notice',
+ isDismissible : true
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/vue/standalone/duplicate-post/assets/scss/main.scss b/src/vue/standalone/duplicate-post/assets/scss/main.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/vue/standalone/duplicate-post/handleCustomCheckbox.js b/src/vue/standalone/duplicate-post/handleCustomCheckbox.js
new file mode 100644
index 0000000..cffb68a
--- /dev/null
+++ b/src/vue/standalone/duplicate-post/handleCustomCheckbox.js
@@ -0,0 +1,49 @@
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+document.addEventListener('DOMContentLoaded', function () {
+ // Check if `#the-list` exists.
+ if (!document.querySelector('#the-list') || !document.querySelector('#bulk_edit')) {
+ return
+ }
+
+ // Bind to the Quick Edit save event.
+ document.querySelector('#the-list').addEventListener('click', function (event) {
+ if (event.target.classList.contains('editinline')) {
+ // Get the post ID.
+ const postRow = event.target.closest('tr')
+ const postId = postRow.id.replace('post-', '')
+
+ setTimeout(async () => {
+ // Get the custom checkbox value.
+ const editRow = document.querySelector('#edit-' + postId)
+ const customCheckbox = editRow.querySelector('input[name="aioseo_remove_original"]').checked ? '1' : ''
+
+ const response = await http.get(links.restUrl(`get-original-post?post_id=${postId}`))
+ if (response.body.success) {
+ // Check the condition and remove the custom fieldset if the condition is met.
+ const customFieldset = editRow.querySelector('.aioseo_remove_original_fieldset')
+ if (customFieldset) {
+ customFieldset.style.opacity = '1'
+ }
+ }
+
+ // Add the custom checkbox value to the inline edit form.
+ editRow.querySelector('input[name="aioseo_remove_original"]').value = customCheckbox
+ }, 0)
+ }
+ })
+
+ // Ensure the custom checkbox value is included in the AJAX request.
+ document.querySelector('#bulk_edit').addEventListener('click', function (event) {
+ if ('bulk_edit_save' === event.target.id) {
+ const bulkEditRow = document.querySelector('#bulk-edit')
+ const bulkEditCheckbox = bulkEditRow.querySelector('input[name="aioseo_custom_checkbox"]').checked ? '1' : ''
+ document.querySelectorAll('input[name="aioseo_custom_checkbox"]').forEach(function (input) {
+ input.value = bulkEditCheckbox
+ })
+ }
+ })
+})
+
+export default {}
\ No newline at end of file
diff --git a/src/vue/standalone/duplicate-post/main.js b/src/vue/standalone/duplicate-post/main.js
new file mode 100644
index 0000000..afd2327
--- /dev/null
+++ b/src/vue/standalone/duplicate-post/main.js
@@ -0,0 +1,30 @@
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+import { isBlockEditor } from '#/vue/utils/context'
+import { showNotices } from './assets/js/notices.js'
+import mergeAndCompare from './assets/js/mergeAndCompare.js'
+
+import './assets/scss/main.scss'
+
+mergeAndCompare()
+
+const ping = async () => {
+ let ping = true
+ try {
+ await http.get(links.restUrl('ping'))
+ } catch (error) {
+ ping = false
+ }
+
+ return ping
+}
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const pingTest = await ping()
+ if (!isBlockEditor() || !pingTest) {
+ return
+ }
+
+ showNotices()
+})
\ No newline at end of file
diff --git a/src/vue/standalone/review-notice/index.js b/src/vue/standalone/review-notice/index.js
new file mode 100644
index 0000000..d364eb1
--- /dev/null
+++ b/src/vue/standalone/review-notice/index.js
@@ -0,0 +1,73 @@
+// import '#/vue/assets/scss/dismiss-notice.scss'
+import '../../assets/scss/dismiss-notice.scss'
+
+window.addEventListener('load', function () {
+ let dismissBtn
+
+ const aioseoDuplicatePostSetupButton = function (dismissBtn) {
+ const notice = document.querySelector('.notice.aioseo-duplicate-post-review-plugin-cta')
+ let delay = false,
+ relay = true
+ const stepOne = notice.querySelector('.step-1')
+ const stepTwo = notice.querySelector('.step-2')
+ const stepThree = notice.querySelector('.step-3')
+
+ // Add an event listener to the dismiss button.
+ dismissBtn.addEventListener('click', function () {
+ const httpRequest = new XMLHttpRequest()
+ let postData = ''
+
+ // Build the data to send in our request.
+ postData += '&delay=' + delay
+ postData += '&relay=' + relay
+ postData += '&action=aioseo-duplicate-post-dismiss-review-plugin-cta'
+ postData += '&nonce=' + window.aioseoDuplicatePost.dismissNonce
+
+ httpRequest.open('POST', window.aioseoDuplicatePost.urls.adminUrl)
+ httpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
+ httpRequest.send(postData)
+ })
+
+ notice.addEventListener('click', function (event) {
+ if (event.target.matches('.aioseo-duplicate-post-review-switch-step-3')) {
+ event.preventDefault()
+ stepOne.style.display = 'none'
+ stepTwo.style.display = 'none'
+ stepThree.style.display = 'block'
+ }
+ if (event.target.matches('.aioseo-duplicate-post-review-switch-step-2')) {
+ event.preventDefault()
+ stepOne.style.display = 'none'
+ stepThree.style.display = 'none'
+ stepTwo.style.display = 'block'
+ }
+ if (event.target.matches('.aioseo-duplicate-post-dismiss-review-notice-delay')) {
+ event.preventDefault()
+ delay = true
+ relay = false
+ dismissBtn.click()
+ }
+ if (event.target.matches('.aioseo-duplicate-post-dismiss-review-notice')) {
+ if ('#' === event.target.getAttribute('href')) {
+ event.preventDefault()
+ }
+ relay = false
+ dismissBtn.click()
+ }
+ })
+ }
+
+ dismissBtn = document.querySelector('.aioseo-duplicate-post-review-plugin-cta .notice-dismiss')
+ if (!dismissBtn) {
+ document.addEventListener('animationstart', function (event) {
+ if ('dismissBtnVisible' === event.animationName) {
+ dismissBtn = document.querySelector('.aioseo-duplicate-post-review-plugin-cta .notice-dismiss')
+ if (dismissBtn) {
+ aioseoDuplicatePostSetupButton(dismissBtn)
+ }
+ }
+ }, false)
+ } else {
+ aioseoDuplicatePostSetupButton(dismissBtn)
+ }
+})
\ No newline at end of file
diff --git a/src/vue/stores/NotificationsStore.js b/src/vue/stores/NotificationsStore.js
new file mode 100644
index 0000000..082ba35
--- /dev/null
+++ b/src/vue/stores/NotificationsStore.js
@@ -0,0 +1,65 @@
+import { defineStore } from 'pinia'
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+const clearNotificationNotices = notifications => {
+ const notificationCount = document.querySelector('.aioseo-menu-notification-counter')
+ if (notificationCount) {
+ if (notifications.active.length) {
+ notificationCount.innerText = notifications.active.length
+ } else {
+ notificationCount.remove()
+ }
+ }
+}
+
+export const useNotificationsStore = defineStore('NotificationStore', {
+ state : () => ({
+ active : [],
+ new : [],
+ dismissed : [],
+ force : false,
+ showNotifications : false
+ }),
+ getters : {
+ activeNotifications : state => state.active,
+ activeNotificationsCount : state => state.active.length,
+ dismissedNotifications : state => state.dismissed,
+ dismissedNotificationsCount : state => state.dismissed.length
+ },
+ actions : {
+ dismissNotifications (notifications) {
+ const reversed = notifications.reverse()
+ reversed.forEach(slug => {
+ const notificationIndex = this.active.findIndex(n => n.slug === slug)
+ if (-1 !== notificationIndex) {
+ this.active.splice(notificationIndex, 1)
+ }
+ })
+
+ return http.post(links.restUrl('notifications/dismiss'))
+ .send(notifications)
+ .then(response => {
+ if (!response.body.success) {
+ throw new Error(response.body.message)
+ }
+
+ this.updateNotifications(response.body.notifications)
+ })
+ },
+ updateNotifications (notifications) {
+ if (notifications.new.length && window.aioseoDuplicatePostNotifications) {
+ window.aioseoDuplicatePostNotifications.newNotifications = notifications.new.length
+ }
+
+ this.active = notifications.active
+ this.new = notifications.new
+ this.dismissed = notifications.dismissed
+
+ clearNotificationNotices(notifications)
+ },
+ toggleNotifications () {
+ this.showNotifications = !this.showNotifications
+ }
+ }
+})
\ No newline at end of file
diff --git a/src/vue/stores/OptionsStore.js b/src/vue/stores/OptionsStore.js
new file mode 100644
index 0000000..becee46
--- /dev/null
+++ b/src/vue/stores/OptionsStore.js
@@ -0,0 +1,63 @@
+import { defineStore } from 'pinia'
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+import {
+ useNotificationsStore,
+ useSettingsStore
+} from '#/vue/stores'
+
+export const useOptionsStore = defineStore('OptionsStore', {
+ state : () => ({
+ internalOptions : {},
+ options : {}
+ }),
+ actions : {
+ getObjects (payload) {
+ return http.post(links.restUrl('objects'))
+ .send(payload)
+ .then(response => {
+ if (!response.body.success) {
+ throw new Error(response.body.message)
+ }
+
+ return response
+ })
+ },
+ saveChanges () {
+ return http.post(links.restUrl('options'))
+ .send({
+ options : this.options
+ })
+ .then(response => {
+ const notificationsStore = useNotificationsStore()
+ notificationsStore.updateNotifications(response.body.notifications)
+
+ return response
+ })
+ },
+ refreshOptions () {
+ return http.get(links.restUrl('options'))
+ .send()
+ .then(response => {
+ if (!response.body.success) {
+ throw new Error(response.body.message)
+ }
+
+ const settingsStore = useSettingsStore()
+
+ this.options = response.body.options
+ this.internalOptions = response.body.internalOptions
+ settingsStore.settings = response.body.settings
+ })
+ },
+ updateOption (store, { groups, key, value }) {
+ let options = this[store]
+ groups.forEach(group => {
+ options = options[group]
+ })
+
+ options[key] = value
+ }
+ }
+})
\ No newline at end of file
diff --git a/src/vue/stores/PluginsStore.js b/src/vue/stores/PluginsStore.js
new file mode 100644
index 0000000..a490aea
--- /dev/null
+++ b/src/vue/stores/PluginsStore.js
@@ -0,0 +1,25 @@
+import { defineStore } from 'pinia'
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+export const usePluginsStore = defineStore('PluginsStore', {
+ state : () => ({
+ plugins : {}
+ }),
+ actions : {
+ installPlugins (plugins) {
+ return http.post(links.restUrl('plugins/install'))
+ .send({
+ network : false,
+ plugins : plugins
+ })
+ .then(response => {
+ if (!response.body.success) {
+ throw new Error(response.body.message)
+ }
+
+ return response
+ })
+ }
+ }
+})
\ No newline at end of file
diff --git a/src/vue/stores/RootStore.js b/src/vue/stores/RootStore.js
new file mode 100644
index 0000000..62d1e6d
--- /dev/null
+++ b/src/vue/stores/RootStore.js
@@ -0,0 +1,45 @@
+import { defineStore } from 'pinia'
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+export const useRootStore = defineStore('RootStore', {
+ state : () => ({
+ pong : true,
+ loading : false,
+ aioseoDuplicatePost : {},
+ modals : {
+ active : null,
+ all : []
+ }
+ }),
+ actions : {
+ ping () {
+ http.get(links.restUrl('ping'))
+ .catch(() => {
+ this.pong = false
+ })
+ },
+ setActiveModal (modal) {
+ this.modals.active = modal
+
+ // Only add the modal to the list of all modals if it is not already in the list.
+ if (!this.modals.all.includes(modal)) {
+ this.modals.all.push(modal)
+ }
+ },
+ unsetActiveModal (activeModal) {
+ // If the active modal is not the modal we are trying to unset, do nothing.
+ if (!this.modals.all.includes(activeModal)) {
+ return
+ }
+
+ // Remove the active modal from the list of all modals.
+ this.modals.all = this.modals.all.filter((modal) => {
+ return modal !== activeModal
+ })
+
+ // Get the last modal in the list of all modals and set it as the active modal.
+ this.modals.active = this.modals.all[this.modals.all.length - 1] || null
+ }
+ }
+})
\ No newline at end of file
diff --git a/src/vue/stores/SettingsStore.js b/src/vue/stores/SettingsStore.js
new file mode 100644
index 0000000..a29c498
--- /dev/null
+++ b/src/vue/stores/SettingsStore.js
@@ -0,0 +1,42 @@
+import { defineStore } from 'pinia'
+import http from '#/vue/utils/http'
+import links from '#/vue/utils/links'
+
+export const useSettingsStore = defineStore('SettingsStore', {
+ state : () => ({
+ settings : {}
+ }),
+ actions : {
+ toggleCard ({ slug, shouldSave }) {
+ this.settings.toggledCards[slug] = !this.settings.toggledCards[slug]
+
+ if (shouldSave) {
+ http.post(links.restUrl('settings/toggle-card'))
+ .send({
+ card : slug
+ })
+ .then(() => {})
+ }
+ },
+ toggleRadio ({ slug, value }) {
+ this.settings.toggledRadio[slug] = value
+
+ http.post(links.restUrl('settings/toggle-radio'))
+ .send({
+ radio : slug,
+ value : value
+ })
+ .then(() => {})
+ },
+ changeItemsPerPage ({ slug, value }) {
+ this.settings.tablePagination[slug] = value
+
+ return http.post(links.restUrl('settings/items-per-page'))
+ .send({
+ table : slug,
+ value : value
+ })
+ .then(() => {})
+ }
+ }
+})
\ No newline at end of file
diff --git a/src/vue/stores/index.js b/src/vue/stores/index.js
new file mode 100644
index 0000000..3ce02d3
--- /dev/null
+++ b/src/vue/stores/index.js
@@ -0,0 +1,91 @@
+import { createPinia } from 'pinia'
+
+import { useNotificationsStore } from '#/vue/stores/NotificationsStore'
+import { useOptionsStore } from '#/vue/stores/OptionsStore'
+import { usePluginsStore } from '#/vue/stores/PluginsStore'
+import { useRootStore } from '#/vue/stores/RootStore'
+import { useSettingsStore } from '#/vue/stores/SettingsStore'
+
+import { markRaw } from 'vue'
+import { merge, mergeWith } from 'lodash-es'
+
+// This customizer ensures that arrays are not merged, but replaced.
+// This is needed to prevent the array options on the window from being merged instead of replaced with the new values.
+// Otherwise, if an included post type is unchecked, it will still be included when you navigate back to the relevant settings page.
+const mergeCustomizer = (_a, b) => {
+ if (Array.isArray(b)) {
+ return b
+ }
+ return undefined
+}
+
+const pinia = createPinia()
+
+const loadPiniaStores = (app, router = null) => {
+ loadPinia(app, router)
+
+ const rootStore = useRootStore()
+
+ // If the stores have been instantiated, bail.
+ if (rootStore.loaded) {
+ return pinia
+ }
+
+ const aioseoDuplicatePost = JSON.parse(JSON.stringify(window.aioseoDuplicatePost || {}))
+
+ // Pinia stores.
+ const notificationsStore = useNotificationsStore()
+ const optionsStore = useOptionsStore()
+ const pluginsStore = usePluginsStore()
+ const settingsStore = useSettingsStore()
+
+ // Options stores.
+ optionsStore.internalOptions = mergeWith({ ...optionsStore.internalOptions }, { ...aioseoDuplicatePost.internalOptions || {} }, mergeCustomizer)
+ optionsStore.options = mergeWith({ ...optionsStore.options }, { ...aioseoDuplicatePost.options || {} }, mergeCustomizer)
+
+ // Other stores.
+ notificationsStore.$state = mergeWith({ ...notificationsStore.$state }, { ...aioseoDuplicatePost.notifications || {} }, mergeCustomizer)
+ pluginsStore.plugins = mergeWith({ ...pluginsStore.plugins }, { ...aioseoDuplicatePost.plugins || {} }, mergeCustomizer)
+ settingsStore.settings = mergeWith({ ...settingsStore.settings }, { ...aioseoDuplicatePost.settings || {} }, mergeCustomizer)
+
+ // Default root store without the above data.
+ delete aioseoDuplicatePost.internalOptions
+ delete aioseoDuplicatePost.notifications
+ delete aioseoDuplicatePost.options
+ delete aioseoDuplicatePost.plugins
+ delete aioseoDuplicatePost.settings
+
+ // Add additional properties.
+ aioseoDuplicatePost.publicPath = '/'
+ aioseoDuplicatePost.translations = {}
+
+ rootStore.aioseoDuplicatePost = merge({ ...rootStore.aioseoDuplicatePost }, { ...aioseoDuplicatePost || {} })
+
+ rootStore.loaded = true
+
+ return pinia
+}
+
+const loadPinia = (app, router = null) => {
+ if (router) {
+ pinia.use(({ store }) => {
+ store.$router = markRaw(router)
+ })
+ }
+
+ app.use(pinia)
+
+ return pinia
+}
+
+export {
+ pinia,
+ loadPinia,
+ loadPiniaStores,
+ // All the stores.
+ useNotificationsStore,
+ useOptionsStore,
+ usePluginsStore,
+ useRootStore,
+ useSettingsStore
+}
\ No newline at end of file
diff --git a/src/vue/utils/allowed.js b/src/vue/utils/allowed.js
new file mode 100644
index 0000000..dfa6d72
--- /dev/null
+++ b/src/vue/utils/allowed.js
@@ -0,0 +1,9 @@
+import {
+ useRootStore
+} from '#/vue/stores'
+
+export const allowed = (permission) => {
+ const rootStore = useRootStore()
+ const user = rootStore.aioseoDuplicatePost.user
+ return !!user.canManage || !!(user.capabilities && user.capabilities[permission])
+}
\ No newline at end of file
diff --git a/src/vue/utils/context.js b/src/vue/utils/context.js
new file mode 100644
index 0000000..7631e05
--- /dev/null
+++ b/src/vue/utils/context.js
@@ -0,0 +1,65 @@
+if (!window.wp?.blockEditor && window.wp?.blocks && window.wp.oldEditor) {
+ window.wp.blockEditor = window.wp.editor
+}
+
+export const isBlockEditor = () => {
+ return document.body.classList.contains('block-editor-page') && window.wp.data && canLoadBlocks()
+}
+
+export const isClassicEditor = () => {
+ return !!document.querySelector('#wp-content-wrap.tmce-active, #wp-content-wrap.html-active')
+}
+
+export const isClassicNoEditor = () => {
+ return document.querySelector('#post input#title') && !document.querySelector('#wp-content-wrap')
+}
+
+export const isElementorEditor = () => {
+ return !!(document.body.classList.contains('elementor-editor-active') && window.elementor)
+}
+
+export const isDiviEditor = () => {
+ return !!(document.body.classList.contains('et_pb_pagebuilder_layout') && window.ET_Builder)
+}
+
+export const isSeedProdEditor = () => {
+ return !!(document.body.classList.contains('seedprod-builder') && window.seedprod_data)
+}
+
+export const isWPBakeryEditor = () => {
+ return !!(window.vc && window.vc_mode)
+}
+
+export const isAvadaEditor = () => {
+ return (window.FusionApp || window.FusionPageBuilderApp)?.builderActive
+}
+
+export const isThriveArchitectEditor = () => {
+ return !!(window.TVE && window.TVE.Editor_Page)
+}
+
+export const isSiteOriginEditor = () => {
+ const visible = (el) => !!(el?.offsetWidth || el?.offsetHeight || el?.getClientRects().length)
+
+ const isBlockEditorPanelsEnabled = document.querySelectorAll('.block-editor-page').length && 'undefined' !== typeof window.soPanelsBuilderView
+ const isClassicEditorPanelsEnabled = visible(document.querySelector('#so-panels-panels.attached-to-editor'))
+
+ return isBlockEditorPanelsEnabled || isClassicEditorPanelsEnabled
+}
+
+export const isPageBuilderEditor = () => {
+ return (
+ isElementorEditor() ||
+ isDiviEditor() ||
+ isSeedProdEditor() ||
+ isWPBakeryEditor() ||
+ isAvadaEditor() ||
+ isSiteOriginEditor() ||
+ isThriveArchitectEditor()
+ )
+}
+
+export const canLoadBlocks = () => {
+ const wp = window.wp
+ return ('undefined' !== typeof wp && 'undefined' !== typeof wp.blocks && 'undefined' !== typeof wp.blockEditor)
+}
\ No newline at end of file
diff --git a/src/vue/utils/helpers.js b/src/vue/utils/helpers.js
new file mode 100644
index 0000000..47219d0
--- /dev/null
+++ b/src/vue/utils/helpers.js
@@ -0,0 +1,161 @@
+export const decodeHTMLEntities = string => {
+ const element = document.createElement('div')
+ if (string && 'string' === typeof string) {
+ // strip script/html tags
+ string = string.replace(/