self::PAGE );
if ( '' !== $tab ) {
$args['tab'] = $tab;
}
return add_query_arg( $args, admin_url( 'options-general.php' ) );
}
/**
* Adds the options page.
*/
public function add_page(): void {
$this->hook = (string) add_options_page(
__( 'Sharely.AI', 'sharelyai' ),
__( 'Sharely.AI', 'sharelyai' ),
'manage_options',
self::PAGE,
array( $this, 'render' )
);
}
/**
* WordPress's id for this screen, once `add_page()` has run.
*
* Empty before then. It is not a constant: WordPress derives the id from
* the parent menu, and that derivation is its business, not ours — which
* is exactly why `enqueue()` compares against this rather than a literal.
*/
public function screen_id(): string {
return $this->hook;
}
/**
* Loads the stylesheet, on this screen only.
*
* @param string $hook The screen WordPress is about to render.
*/
public function enqueue( string $hook ): void {
if ( '' === $this->hook || $hook !== $this->hook ) {
return;
}
wp_enqueue_style(
self::STYLE,
PLUGIN_URL . 'assets/css/admin.css',
array(),
Assets::version( 'assets/css/admin.css' )
);
// The poller, and only while there is something to poll. A screen with
// no sync running has nothing to ask about, and asking anyway would be
// a request every few seconds for the life of the tab.
if ( ! ( new Sync( $this->connection ) )->is_running() ) {
return;
}
wp_enqueue_script(
self::SCRIPT,
PLUGIN_URL . 'assets/js/admin.js',
array(),
Assets::version( 'assets/js/admin.js' ),
true
);
wp_add_inline_script(
self::SCRIPT,
'window.sharelyaiProgress = ' . wp_json_encode(
array(
'url' => admin_url( 'admin-ajax.php' ),
'action' => Progress::ACTION,
'nonce' => wp_create_nonce( Progress::ACTION ),
'running' => true,
)
) . ';',
'before'
);
}
/**
* Renders whichever tab is asked for.
*/
public function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- choosing a tab to read is not a change.
$tab = isset( $_GET['tab'] ) && 'settings' === $_GET['tab'] ? 'settings' : 'status';
?>
render_notice(); ?>
render_settings();
} else {
( new Status( $this->connection ) )->render();
}
?>
connection->post_types();
$retention = (int) $this->connection->get( 'stats_retention_days', 30 );
// Which workspace this is configuring, before the settings that
// configure it. It used to come after, which left Save Changes stranded
// between the form and a card it had nothing to do with.
$this->render_connection_readout();
?>
connection->is_connected() ) {
return;
}
?>
' . esc_html( $this->workspace_label() ) . '',
esc_html( (string) $this->connection->get( 'key_last4' ) )
);
?>
%s',
esc_url( self::page_url() ),
esc_html__( 'Status and disconnect are on the Status tab.', 'sharelyai' )
);
?>
connection->get( 'workspace_name' );
return '' !== $name ? $name : $this->connection->workspace_id();
}
/**
* Saves the settings.
*/
public function handle_save(): void {
$this->guard( 'sharelyai_save' );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- guard() calls check_admin_referer() above; the sniff cannot see through it.
// Sanitized on the next line, per value: sanitize_key() is what applies to
// a post type name, and it cannot be applied to the array itself.
$posted = isset( $_POST['post_types'] ) ? (array) wp_unslash( $_POST['post_types'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$types = array_values(
array_intersect(
Connection::public_post_types(),
array_map( 'sanitize_key', array_map( 'strval', $posted ) )
)
);
$retention = isset( $_POST['stats_retention_days'] ) ? (int) $_POST['stats_retention_days'] : 30;
$enabled = isset( $_POST['stats_enabled'] );
$search = isset( $_POST['search_enabled'] );
$ask = isset( $_POST['ask_enabled'] );
// Private entries need the workspace to be filtering by role at all.
// Letting this be ticked on a workspace with permissions off would
// publish members-only content to every visitor.
$private = isset( $_POST['sync_private'] ) && (bool) $this->connection->get( 'rbac_enabled' );
$ask_search = isset( $_POST['ask_on_search'] );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$this->connection->save(
array(
// An array, even an empty one: that is what "none, deliberately"
// looks like, as against the null of never having been asked.
'post_types' => $types,
'search_enabled' => $search,
'sync_private' => $private,
'ask_enabled' => $ask,
'ask_on_search' => $ask_search,
'stats_enabled' => $enabled,
'stats_retention_days' => min( 365, max( 1, $retention ) ),
)
);
$this->redirect_with( 'success', __( 'Settings saved.', 'sharelyai' ), 'settings' );
}
/**
* Starts a sync.
*/
public function handle_sync(): void {
$this->guard( 'sharelyai_sync' );
$started = ( new Sync( $this->connection ) )->start_full();
if ( ! $started['ok'] ) {
$this->redirect_with( 'error', $started['message'] );
}
if ( 0 === $started['total'] ) {
$this->redirect_with(
'error',
__( 'Nothing to send: no published entry is in scope. Check the post types under Settings.', 'sharelyai' )
);
}
$this->redirect_with(
'success',
sprintf(
/* translators: %s: number of entries. */
_n(
'Sending %s entry to Sharely. This page shows the progress.',
'Sending %s entries to Sharely. This page shows the progress.',
$started['total'],
'sharelyai'
),
number_format_i18n( $started['total'] )
)
);
}
/**
* Clears the credentials. Nothing in Sharely is touched.
*
* The local record of what was sent goes too: without credentials those
* knowledge ids cannot be used for anything, and keeping them would make a
* later reconnection believe the workspace already holds this site.
*/
public function handle_disconnect(): void {
$this->guard( 'sharelyai_disconnect' );
// First, while the credentials are still here to authenticate with:
// tell Sharely, so the site stops showing as connected there too.
$announced = ( new Connect( $this->connection ) )->announce_disconnect();
( new Queue() )->cancel_all();
( new Sync( $this->connection ) )->forget_all();
( new Stats( $this->connection ) )->clear();
// Before the connection goes, because both of these are keyed by the
// workspace it names. A bearer and a set of role ids belong to one
// workspace and are meaningless — or wrong — in the next.
( new Credentials( $this->connection ) )->forget();
( new Roles( $this->connection ) )->forget();
$this->connection->forget();
$this->redirect_with(
'success',
$announced
? __( 'Disconnected. Sharely has been told, and your workspace and its content are untouched.', 'sharelyai' )
: __( 'Disconnected here. Sharely could not be reached, so the site may still be listed there — remove it from Connected sites in Sharely. Your workspace and its content are untouched.', 'sharelyai' )
);
}
/**
* Forgets the recorded searches, keeping the connection.
*/
public function handle_stats_clear(): void {
$this->guard( 'sharelyai_stats_clear' );
( new Stats( $this->connection ) )->clear();
$this->redirect_with( 'success', __( 'Search statistics cleared.', 'sharelyai' ) );
}
/**
* Only an administrator, only from this screen.
*
* @param string $action Nonce action.
*/
private function guard( string $action ): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do that.', 'sharelyai' ), 403 );
}
check_admin_referer( $action );
}
/**
* Back to the screen with a message.
*
* @param string $type success|error.
* @param string $message What to say.
* @param string $tab Which tab to return to.
*/
private function redirect_with( string $type, string $message, string $tab = '' ): void {
set_transient(
'sharelyai_notice',
array(
'type' => $type,
'message' => $message,
),
60
);
wp_safe_redirect( self::page_url( $tab ) );
exit;
}
}